feat: integrate DeltaIns, Tufts SCO, United SCO, and CCA eligibility checks
This commit is contained in:
@@ -12,6 +12,9 @@ import { runClaimStatusProcessor } from "./processors/claimStatusProcessor";
|
||||
import { runClaimSubmitProcessor } from "./processors/claimSubmitProcessor";
|
||||
import { runOcrProcessor } from "./processors/ocrProcessor";
|
||||
import { runDdmaEligibilityProcessor } from "./processors/ddmaEligibilityProcessor";
|
||||
import { runDeltaInsEligibilityProcessor } from "./processors/deltaInsEligibilityProcessor";
|
||||
import { runUnitedSCOEligibilityProcessor } from "./processors/unitedSCOEligibilityProcessor";
|
||||
import { runCCAEligibilityProcessor } from "./processors/ccaEligibilityProcessor";
|
||||
import type { SeleniumJobData, OcrJobData } from "./queues";
|
||||
|
||||
// ── Queue instances ──────────────────────────────────────────────────────────
|
||||
@@ -83,6 +86,48 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "deltains-eligibility-check") {
|
||||
return runDeltaInsEligibilityProcessor(
|
||||
{
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
insuranceId: data.insuranceId!,
|
||||
formFirstName: data.formFirstName,
|
||||
formLastName: data.formLastName,
|
||||
formDob: data.formDob,
|
||||
socketId: data.socketId,
|
||||
},
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "unitedsco-eligibility-check") {
|
||||
return runUnitedSCOEligibilityProcessor(
|
||||
{
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
insuranceId: data.insuranceId!,
|
||||
formFirstName: data.formFirstName,
|
||||
formLastName: data.formLastName,
|
||||
formDob: data.formDob,
|
||||
socketId: data.socketId,
|
||||
},
|
||||
job.id
|
||||
);
|
||||
}
|
||||
if (jobType === "cca-eligibility-check") {
|
||||
return runCCAEligibilityProcessor(
|
||||
{
|
||||
enrichedPayload: data.enrichedPayload,
|
||||
userId: data.userId,
|
||||
insuranceId: data.insuranceId!,
|
||||
formFirstName: data.formFirstName,
|
||||
formLastName: data.formLastName,
|
||||
formDob: data.formDob,
|
||||
socketId: data.socketId,
|
||||
},
|
||||
job.id
|
||||
);
|
||||
}
|
||||
throw new Error(`Unknown selenium jobType: ${jobType}`);
|
||||
});
|
||||
|
||||
|
||||
309
apps/Backend/src/queue/processors/ccaEligibilityProcessor.ts
Normal file
309
apps/Backend/src/queue/processors/ccaEligibilityProcessor.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Processor for "cca-eligibility-check" jobs.
|
||||
*
|
||||
* CCA (Commonwealth Care Alliance) uses ScionDental portal.
|
||||
* No OTP required — simple username/password persistent session.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Start a session on the Python agent (POST /cca-eligibility)
|
||||
* 2. Emit selenium:cca_session_started → frontend stores session_id
|
||||
* 3. Poll agent status until completed/error (no OTP handling needed)
|
||||
* 4. On completion: decode pdfBase64, save PDF, create/update patient, update status
|
||||
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
|
||||
*
|
||||
* CCA result returns pdfBase64 (base64-encoded PDF), same as DeltaIns.
|
||||
*/
|
||||
import { storage } from "../../storage";
|
||||
import {
|
||||
forwardToSeleniumCCAEligibilityAgent,
|
||||
getSeleniumCCASessionStatus,
|
||||
} from "../../services/seleniumCCAEligibilityClient";
|
||||
import { splitName, createOrUpdatePatientByInsuranceId } from "./_shared";
|
||||
import { io } from "../../socket";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
socket.emit(event, payload);
|
||||
log("cca-processor", `emitted ${event}`, { socketId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
log("cca-processor", `emit failed for ${event}`, { err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CCAEligibilityProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
insuranceId: string;
|
||||
formFirstName?: string;
|
||||
formLastName?: string;
|
||||
formDob?: string;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export interface CCAEligibilityProcessorResult {
|
||||
patientUpdateStatus?: string;
|
||||
pdfUploadStatus?: string;
|
||||
pdfFileId?: number | null;
|
||||
pdfFilename?: string | null;
|
||||
}
|
||||
|
||||
// ─── Core DB processing ───────────────────────────────────────────────────────
|
||||
|
||||
async function processCCAResult(
|
||||
userId: number,
|
||||
insuranceId: string,
|
||||
formFirstName: string | undefined,
|
||||
formLastName: string | undefined,
|
||||
formDob: string | undefined,
|
||||
seleniumResult: any
|
||||
): Promise<CCAEligibilityProcessorResult> {
|
||||
const output: CCAEligibilityProcessorResult = {};
|
||||
let createdPdfFileId: number | null = null;
|
||||
|
||||
try {
|
||||
// 1) Resolve patient name
|
||||
const rawName =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = rawName
|
||||
? splitName(rawName)
|
||||
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||
|
||||
// 2) Create / update patient
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: formDob,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 3) Fetch patient
|
||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
if (!patient?.id) {
|
||||
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||
return output;
|
||||
}
|
||||
|
||||
// 4) Determine eligibility status
|
||||
// Python returns "Eligible" / "Not Eligible" / "Unknown"
|
||||
const eligRaw: string = seleniumResult?.eligibility ?? "";
|
||||
const eligLower = eligRaw.toLowerCase();
|
||||
const newStatus =
|
||||
eligLower === "eligible" || eligLower === "active" || eligLower === "y"
|
||||
? "ACTIVE"
|
||||
: "INACTIVE";
|
||||
|
||||
// Use insurerName from result if available, fall back to default
|
||||
const insuranceProvider =
|
||||
typeof seleniumResult?.insurerName === "string" && seleniumResult.insurerName.trim()
|
||||
? seleniumResult.insurerName.trim()
|
||||
: "Commonwealth Care Alliance";
|
||||
|
||||
await storage.updatePatient(patient.id, {
|
||||
status: newStatus,
|
||||
insuranceProvider,
|
||||
});
|
||||
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// 5) Decode pdfBase64 → Buffer
|
||||
const pdfBase64: string = seleniumResult?.pdfBase64 ?? "";
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let pdfFilename: string | null = null;
|
||||
|
||||
if (pdfBase64) {
|
||||
try {
|
||||
pdfBuffer = Buffer.from(pdfBase64, "base64");
|
||||
pdfFilename = `cca_eligibility_${insuranceId}_${Date.now()}.pdf`;
|
||||
log("cca-processor", "decoded pdfBase64", { bytes: pdfBuffer.length });
|
||||
} catch (e: any) {
|
||||
output.pdfUploadStatus = `Failed to decode PDF base64: ${e.message}`;
|
||||
}
|
||||
} else {
|
||||
output.pdfUploadStatus = "No PDF data returned from Selenium.";
|
||||
}
|
||||
|
||||
// 6) Save PDF to patient document group
|
||||
if (pdfBuffer && pdfFilename) {
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
const groupTitle = "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");
|
||||
|
||||
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
output.pdfFilename = pdfFilename;
|
||||
}
|
||||
|
||||
output.pdfFileId = createdPdfFileId;
|
||||
return output;
|
||||
} catch (err: any) {
|
||||
return {
|
||||
...output,
|
||||
pdfUploadStatus:
|
||||
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Polling loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async function pollUntilDone(
|
||||
sessionId: string,
|
||||
pollTimeoutMs = 5 * 60 * 1000
|
||||
): Promise<any> {
|
||||
const maxAttempts = 600;
|
||||
const pollIntervalMs = 500;
|
||||
const maxTransientErrors = 12;
|
||||
const noProgressLimit = 120;
|
||||
|
||||
let transientErrors = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`CCA polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const st = await getSeleniumCCASessionStatus(sessionId);
|
||||
const status: string = st?.status ?? "unknown";
|
||||
|
||||
log("cca-processor", `poll attempt=${attempt}`, { sessionId, status });
|
||||
|
||||
transientErrors = 0;
|
||||
|
||||
const isTerminal =
|
||||
status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminal) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
throw new Error(
|
||||
`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
log("cca-processor", "session completed", { sessionId });
|
||||
return st.result;
|
||||
}
|
||||
|
||||
if (status === "error" || status === "not_found") {
|
||||
throw new Error(st?.message || `CCA session ended with status: ${status}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
} catch (err: any) {
|
||||
const isTerminal =
|
||||
err?.response?.status === 404 ||
|
||||
(typeof err?.message === "string" &&
|
||||
(err.message.includes("not_found") ||
|
||||
err.message.includes("polling timeout") ||
|
||||
err.message.includes("No progress")));
|
||||
|
||||
if (isTerminal) throw err;
|
||||
|
||||
transientErrors++;
|
||||
if (transientErrors > maxTransientErrors) {
|
||||
throw new Error(
|
||||
`Too many transient network errors polling CCA session ${sessionId}`
|
||||
);
|
||||
}
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||
log("cca-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
|
||||
err: err?.message,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`CCA polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
// ─── Main processor entry point ───────────────────────────────────────────────
|
||||
|
||||
export async function runCCAEligibilityProcessor(
|
||||
input: CCAEligibilityProcessorInput,
|
||||
jobId: string
|
||||
): Promise<CCAEligibilityProcessorResult> {
|
||||
const {
|
||||
enrichedPayload,
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
socketId,
|
||||
} = input;
|
||||
|
||||
// 1) Tell Python agent to start a CCA session
|
||||
log("cca-processor", "starting Python agent session", { insuranceId });
|
||||
const agentResp = await forwardToSeleniumCCAEligibilityAgent(enrichedPayload);
|
||||
|
||||
if (!agentResp?.session_id) {
|
||||
throw new Error("Python agent did not return a session_id for CCA eligibility");
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
log("cca-processor", "got session_id", { sessionId });
|
||||
|
||||
// 2) Emit session started so frontend can track progress
|
||||
emitToSocket(socketId, "selenium:cca_session_started", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
// 3) Poll until done (no OTP required for CCA)
|
||||
const seleniumResult = await pollUntilDone(sessionId);
|
||||
|
||||
if (!seleniumResult || seleniumResult.status === "error") {
|
||||
throw new Error(seleniumResult?.message ?? "CCA session returned an error result");
|
||||
}
|
||||
|
||||
// 4) Process DB writes and PDF upload
|
||||
log("cca-processor", "processing DB result", { insuranceId });
|
||||
const result = await processCCAResult(
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
seleniumResult
|
||||
);
|
||||
|
||||
log("cca-processor", "done", { result });
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Processor for "deltains-eligibility-check" jobs.
|
||||
*
|
||||
* Mirrors the DDMA persistent-session flow but for Delta Dental Ins (Okta-based):
|
||||
* 1. Start a session on the Python agent (POST /deltains-eligibility)
|
||||
* 2. Emit selenium:deltains_session_started → frontend stores session_id for OTP
|
||||
* 3. Poll agent status, emitting selenium:otp_required when OTP is needed
|
||||
* 4. On completion: decode pdfBase64, save PDF, create/update patient, update status
|
||||
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
|
||||
*
|
||||
* DeltaIns result returns pdfBase64 (base64-encoded PDF) instead of a file path.
|
||||
*/
|
||||
import { storage } from "../../storage";
|
||||
import {
|
||||
forwardToSeleniumDeltaInsEligibilityAgent,
|
||||
getSeleniumDeltaInsSessionStatus,
|
||||
} from "../../services/seleniumDeltaInsEligibilityClient";
|
||||
import { splitName, createOrUpdatePatientByInsuranceId } from "./_shared";
|
||||
import { io } from "../../socket";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
socket.emit(event, payload);
|
||||
log("deltains-processor", `emitted ${event}`, { socketId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
log("deltains-processor", `emit failed for ${event}`, { err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DeltaInsEligibilityProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
insuranceId: string;
|
||||
formFirstName?: string;
|
||||
formLastName?: string;
|
||||
formDob?: string;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export interface DeltaInsEligibilityProcessorResult {
|
||||
patientUpdateStatus?: string;
|
||||
pdfUploadStatus?: string;
|
||||
pdfFileId?: number | null;
|
||||
pdfFilename?: string | null;
|
||||
}
|
||||
|
||||
// ─── Core DB processing ───────────────────────────────────────────────────────
|
||||
|
||||
async function processDeltaInsResult(
|
||||
userId: number,
|
||||
insuranceId: string,
|
||||
formFirstName: string | undefined,
|
||||
formLastName: string | undefined,
|
||||
formDob: string | undefined,
|
||||
seleniumResult: any
|
||||
): Promise<DeltaInsEligibilityProcessorResult> {
|
||||
const output: DeltaInsEligibilityProcessorResult = {};
|
||||
let createdPdfFileId: number | null = null;
|
||||
|
||||
try {
|
||||
// 1) Resolve patient name
|
||||
const rawName =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = rawName
|
||||
? splitName(rawName)
|
||||
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||
|
||||
// 2) Create / update patient
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: formDob,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 3) Fetch patient
|
||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
if (!patient?.id) {
|
||||
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||
return output;
|
||||
}
|
||||
|
||||
// 4) Determine eligibility status
|
||||
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
|
||||
const newStatus =
|
||||
eligStatus === "eligible" || eligStatus === "active" || eligStatus === "y"
|
||||
? "ACTIVE"
|
||||
: "INACTIVE";
|
||||
|
||||
await storage.updatePatient(patient.id, {
|
||||
status: newStatus,
|
||||
insuranceProvider: "Delta Dental Ins",
|
||||
});
|
||||
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// 5) Decode pdfBase64 → Buffer
|
||||
const pdfBase64: string = seleniumResult?.pdfBase64 ?? "";
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let pdfFilename: string | null = null;
|
||||
|
||||
if (pdfBase64) {
|
||||
try {
|
||||
pdfBuffer = Buffer.from(pdfBase64, "base64");
|
||||
pdfFilename = `deltains_eligibility_${insuranceId}_${Date.now()}.pdf`;
|
||||
log("deltains-processor", "decoded pdfBase64", { bytes: pdfBuffer.length });
|
||||
} catch (e: any) {
|
||||
output.pdfUploadStatus = `Failed to decode PDF base64: ${e.message}`;
|
||||
}
|
||||
} else {
|
||||
output.pdfUploadStatus = "No PDF data returned from Selenium.";
|
||||
}
|
||||
|
||||
// 6) Save PDF to patient document group
|
||||
if (pdfBuffer && pdfFilename) {
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
const groupTitle = "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");
|
||||
|
||||
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
output.pdfFilename = pdfFilename;
|
||||
}
|
||||
|
||||
output.pdfFileId = createdPdfFileId;
|
||||
return output;
|
||||
} catch (err: any) {
|
||||
return {
|
||||
...output,
|
||||
pdfUploadStatus:
|
||||
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Polling loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async function pollUntilDone(
|
||||
sessionId: string,
|
||||
socketId: string | undefined,
|
||||
jobId: string,
|
||||
pollTimeoutMs = 5 * 60 * 1000
|
||||
): Promise<any> {
|
||||
const maxAttempts = 600;
|
||||
const pollIntervalMs = 500;
|
||||
const maxTransientErrors = 12;
|
||||
const noProgressLimit = 120;
|
||||
|
||||
let transientErrors = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`DeltaIns polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const st = await getSeleniumDeltaInsSessionStatus(sessionId);
|
||||
const status: string = st?.status ?? "unknown";
|
||||
|
||||
log("deltains-processor", `poll attempt=${attempt}`, { sessionId, status });
|
||||
|
||||
transientErrors = 0;
|
||||
|
||||
const isTerminal =
|
||||
status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminal) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
throw new Error(
|
||||
`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "waiting_for_otp") {
|
||||
emitToSocket(socketId, "selenium:otp_required", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
message: "OTP required. Please enter the code sent to your email.",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
log("deltains-processor", "session completed", { sessionId });
|
||||
return st.result;
|
||||
}
|
||||
|
||||
if (status === "error" || status === "not_found") {
|
||||
throw new Error(st?.message || `DeltaIns session ended with status: ${status}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
} catch (err: any) {
|
||||
const isTerminal =
|
||||
err?.response?.status === 404 ||
|
||||
(typeof err?.message === "string" &&
|
||||
(err.message.includes("not_found") ||
|
||||
err.message.includes("polling timeout") ||
|
||||
err.message.includes("No progress")));
|
||||
|
||||
if (isTerminal) throw err;
|
||||
|
||||
transientErrors++;
|
||||
if (transientErrors > maxTransientErrors) {
|
||||
throw new Error(
|
||||
`Too many transient network errors polling DeltaIns session ${sessionId}`
|
||||
);
|
||||
}
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||
log("deltains-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
|
||||
err: err?.message,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`DeltaIns polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
// ─── Main processor entry point ───────────────────────────────────────────────
|
||||
|
||||
export async function runDeltaInsEligibilityProcessor(
|
||||
input: DeltaInsEligibilityProcessorInput,
|
||||
jobId: string
|
||||
): Promise<DeltaInsEligibilityProcessorResult> {
|
||||
const {
|
||||
enrichedPayload,
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
socketId,
|
||||
} = input;
|
||||
|
||||
// 1) Tell Python agent to start a DeltaIns session
|
||||
log("deltains-processor", "starting Python agent session", { insuranceId });
|
||||
const agentResp = await forwardToSeleniumDeltaInsEligibilityAgent(enrichedPayload);
|
||||
|
||||
if (!agentResp?.session_id) {
|
||||
throw new Error("Python agent did not return a session_id for DeltaIns eligibility");
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
log("deltains-processor", "got session_id", { sessionId });
|
||||
|
||||
// 2) Emit session started so frontend can store session_id for OTP submission
|
||||
emitToSocket(socketId, "selenium:deltains_session_started", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
// 3) Poll until done (handles OTP events internally)
|
||||
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
|
||||
|
||||
if (!seleniumResult || seleniumResult.status === "error") {
|
||||
throw new Error(seleniumResult?.message ?? "DeltaIns session returned an error result");
|
||||
}
|
||||
|
||||
// 4) Process DB writes and PDF upload
|
||||
log("deltains-processor", "processing DB result", { insuranceId });
|
||||
const result = await processDeltaInsResult(
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
seleniumResult
|
||||
);
|
||||
|
||||
log("deltains-processor", "done", { result });
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Processor for "unitedsco-eligibility-check" jobs (Tufts SCO / UnitedHealthcare MA).
|
||||
*
|
||||
* Same persistent-session flow as DDMA:
|
||||
* 1. Start a session on the Python agent (POST /unitedsco-eligibility)
|
||||
* 2. Emit selenium:unitedsco_session_started → frontend stores session_id for OTP
|
||||
* 3. Poll agent status, emitting selenium:otp_required when OTP is needed
|
||||
* 4. On completion: save PDF (file path), create/update patient, update status
|
||||
* 5. Return { pdfFileId, pdfFilename, patientUpdateStatus, pdfUploadStatus }
|
||||
*/
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { storage } from "../../storage";
|
||||
import { emptyFolderContainingFile } from "../../utils/emptyTempFolder";
|
||||
import {
|
||||
forwardToSeleniumUnitedSCOEligibilityAgent,
|
||||
getSeleniumUnitedSCOSessionStatus,
|
||||
} from "../../services/seleniumUnitedSCOEligibilityClient";
|
||||
import { splitName, createOrUpdatePatientByInsuranceId, imageToPdfBuffer } from "./_shared";
|
||||
import { io } from "../../socket";
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${now()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitToSocket(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) {
|
||||
socket.emit(event, payload);
|
||||
log("unitedsco-processor", `emitted ${event}`, { socketId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
log("unitedsco-processor", `emit failed for ${event}`, { err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UnitedSCOEligibilityProcessorInput {
|
||||
enrichedPayload: any;
|
||||
userId: number;
|
||||
insuranceId: string;
|
||||
formFirstName?: string;
|
||||
formLastName?: string;
|
||||
formDob?: string;
|
||||
socketId?: string;
|
||||
}
|
||||
|
||||
export interface UnitedSCOEligibilityProcessorResult {
|
||||
patientUpdateStatus?: string;
|
||||
pdfUploadStatus?: string;
|
||||
pdfFileId?: number | null;
|
||||
pdfFilename?: string | null;
|
||||
}
|
||||
|
||||
// ─── Core DB processing ───────────────────────────────────────────────────────
|
||||
|
||||
async function processUnitedSCOResult(
|
||||
userId: number,
|
||||
insuranceId: string,
|
||||
formFirstName: string | undefined,
|
||||
formLastName: string | undefined,
|
||||
formDob: string | undefined,
|
||||
seleniumResult: any
|
||||
): Promise<UnitedSCOEligibilityProcessorResult> {
|
||||
const output: UnitedSCOEligibilityProcessorResult = {};
|
||||
let createdPdfFileId: number | null = null;
|
||||
|
||||
try {
|
||||
// 1) Resolve patient name
|
||||
const rawName =
|
||||
typeof seleniumResult?.patientName === "string"
|
||||
? seleniumResult.patientName.trim()
|
||||
: null;
|
||||
|
||||
const { firstName, lastName } = rawName
|
||||
? splitName(rawName)
|
||||
: { firstName: formFirstName ?? "", lastName: formLastName ?? "" };
|
||||
|
||||
// 2) Create / update patient
|
||||
await createOrUpdatePatientByInsuranceId({
|
||||
insuranceId,
|
||||
firstName,
|
||||
lastName,
|
||||
dob: formDob,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 3) Fetch patient
|
||||
const patient = await storage.getPatientByInsuranceId(insuranceId);
|
||||
if (!patient?.id) {
|
||||
output.patientUpdateStatus = "Patient not found; no update performed";
|
||||
return output;
|
||||
}
|
||||
|
||||
// 4) Determine eligibility status
|
||||
const eligStatus = (seleniumResult?.eligibility ?? "").toLowerCase();
|
||||
const newStatus = eligStatus === "active" || eligStatus === "y" ? "ACTIVE" : "INACTIVE";
|
||||
|
||||
await storage.updatePatient(patient.id, {
|
||||
status: newStatus,
|
||||
insuranceProvider: "United Healthcare SCO",
|
||||
});
|
||||
output.patientUpdateStatus = `Patient status updated to ${newStatus}`;
|
||||
|
||||
// 5) Resolve PDF buffer from file path (same as DDMA)
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
let pdfFilename: string | null = null;
|
||||
|
||||
const pdfPath: string | null =
|
||||
seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
|
||||
|
||||
if (pdfPath && fsSync.existsSync(pdfPath)) {
|
||||
if (pdfPath.endsWith(".pdf")) {
|
||||
try {
|
||||
pdfBuffer = await fs.readFile(pdfPath);
|
||||
pdfFilename = path.basename(pdfPath);
|
||||
log("unitedsco-processor", "read PDF directly", { pdfPath });
|
||||
} catch (e: any) {
|
||||
output.pdfUploadStatus = `Failed to read PDF: ${e.message}`;
|
||||
}
|
||||
} else if (
|
||||
pdfPath.endsWith(".png") ||
|
||||
pdfPath.endsWith(".jpg") ||
|
||||
pdfPath.endsWith(".jpeg")
|
||||
) {
|
||||
try {
|
||||
pdfBuffer = await imageToPdfBuffer(pdfPath);
|
||||
pdfFilename = `unitedsco_eligibility_${insuranceId}_${Date.now()}.pdf`;
|
||||
log("unitedsco-processor", "converted screenshot to PDF", { pdfPath });
|
||||
} catch (e: any) {
|
||||
output.pdfUploadStatus = `Failed to convert screenshot to PDF: ${e.message}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.pdfUploadStatus = "No valid file path from Selenium; nothing uploaded.";
|
||||
}
|
||||
|
||||
// 6) Save PDF to patient document group
|
||||
if (pdfBuffer && pdfFilename) {
|
||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
||||
const groupTitle = "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");
|
||||
|
||||
const created = await storage.createPdfFile(group.id, pdfFilename, pdfBuffer);
|
||||
if (created && typeof created === "object" && "id" in created) {
|
||||
createdPdfFileId = Number(created.id);
|
||||
}
|
||||
output.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
||||
output.pdfFilename = pdfFilename;
|
||||
}
|
||||
|
||||
output.pdfFileId = createdPdfFileId;
|
||||
return output;
|
||||
} catch (err: any) {
|
||||
return {
|
||||
...output,
|
||||
pdfUploadStatus:
|
||||
output.pdfUploadStatus ?? `Processing failed: ${err?.message ?? String(err)}`,
|
||||
pdfFileId: createdPdfFileId,
|
||||
};
|
||||
} finally {
|
||||
const cleanupPath = seleniumResult?.pdf_path ?? seleniumResult?.ss_path ?? null;
|
||||
if (cleanupPath) {
|
||||
try {
|
||||
await emptyFolderContainingFile(cleanupPath);
|
||||
} catch (e) {
|
||||
log("unitedsco-processor", "cleanup failed", { cleanupPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Polling loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async function pollUntilDone(
|
||||
sessionId: string,
|
||||
socketId: string | undefined,
|
||||
jobId: string,
|
||||
pollTimeoutMs = 5 * 60 * 1000
|
||||
): Promise<any> {
|
||||
const maxAttempts = 600;
|
||||
const pollIntervalMs = 500;
|
||||
const maxTransientErrors = 12;
|
||||
const noProgressLimit = 120;
|
||||
|
||||
let transientErrors = 0;
|
||||
let consecutiveNoProgress = 0;
|
||||
let lastStatus: string | null = null;
|
||||
const deadline = Date.now() + pollTimeoutMs;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`UnitedSCO polling timeout (${Math.round(pollTimeoutMs / 1000)}s) for session ${sessionId}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const st = await getSeleniumUnitedSCOSessionStatus(sessionId);
|
||||
const status: string = st?.status ?? "unknown";
|
||||
|
||||
log("unitedsco-processor", `poll attempt=${attempt}`, { sessionId, status });
|
||||
|
||||
transientErrors = 0;
|
||||
|
||||
const isTerminal =
|
||||
status === "completed" || status === "error" || status === "not_found";
|
||||
if (status === lastStatus && !isTerminal) {
|
||||
consecutiveNoProgress++;
|
||||
} else {
|
||||
consecutiveNoProgress = 0;
|
||||
}
|
||||
lastStatus = status;
|
||||
|
||||
if (consecutiveNoProgress >= noProgressLimit) {
|
||||
throw new Error(
|
||||
`No progress from Python agent (status="${status}") after ${consecutiveNoProgress} polls`
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "waiting_for_otp") {
|
||||
emitToSocket(socketId, "selenium:otp_required", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
message: "OTP required. Please enter the verification code.",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === "completed") {
|
||||
log("unitedsco-processor", "session completed", { sessionId });
|
||||
return st.result;
|
||||
}
|
||||
|
||||
if (status === "error" || status === "not_found") {
|
||||
throw new Error(st?.message || `UnitedSCO session ended with status: ${status}`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
} catch (err: any) {
|
||||
const isTerminal =
|
||||
err?.response?.status === 404 ||
|
||||
(typeof err?.message === "string" &&
|
||||
(err.message.includes("not_found") ||
|
||||
err.message.includes("polling timeout") ||
|
||||
err.message.includes("No progress")));
|
||||
|
||||
if (isTerminal) throw err;
|
||||
|
||||
transientErrors++;
|
||||
if (transientErrors > maxTransientErrors) {
|
||||
throw new Error(
|
||||
`Too many transient network errors polling UnitedSCO session ${sessionId}`
|
||||
);
|
||||
}
|
||||
const backoff = Math.min(30_000, 500 * Math.pow(2, transientErrors - 1));
|
||||
log("unitedsco-processor", `transient error #${transientErrors}, backoff ${backoff}ms`, {
|
||||
err: err?.message,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, backoff));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`UnitedSCO polling exhausted all attempts for session ${sessionId}`);
|
||||
}
|
||||
|
||||
// ─── Main processor entry point ───────────────────────────────────────────────
|
||||
|
||||
export async function runUnitedSCOEligibilityProcessor(
|
||||
input: UnitedSCOEligibilityProcessorInput,
|
||||
jobId: string
|
||||
): Promise<UnitedSCOEligibilityProcessorResult> {
|
||||
const {
|
||||
enrichedPayload,
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
socketId,
|
||||
} = input;
|
||||
|
||||
log("unitedsco-processor", "starting Python agent session", { insuranceId });
|
||||
const agentResp = await forwardToSeleniumUnitedSCOEligibilityAgent(enrichedPayload);
|
||||
|
||||
if (!agentResp?.session_id) {
|
||||
throw new Error("Python agent did not return a session_id for UnitedSCO eligibility");
|
||||
}
|
||||
|
||||
const sessionId = agentResp.session_id as string;
|
||||
log("unitedsco-processor", "got session_id", { sessionId });
|
||||
|
||||
emitToSocket(socketId, "selenium:unitedsco_session_started", {
|
||||
session_id: sessionId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
const seleniumResult = await pollUntilDone(sessionId, socketId, jobId);
|
||||
|
||||
if (!seleniumResult || seleniumResult.status === "error") {
|
||||
throw new Error(seleniumResult?.message ?? "UnitedSCO session returned an error result");
|
||||
}
|
||||
|
||||
log("unitedsco-processor", "processing DB result", { insuranceId });
|
||||
const result = await processUnitedSCOResult(
|
||||
userId,
|
||||
insuranceId,
|
||||
formFirstName,
|
||||
formLastName,
|
||||
formDob,
|
||||
seleniumResult
|
||||
);
|
||||
|
||||
log("unitedsco-processor", "done", { result });
|
||||
return result;
|
||||
}
|
||||
@@ -7,7 +7,10 @@ export type SeleniumJobType =
|
||||
| "claim-status-check"
|
||||
| "claim-submit"
|
||||
| "claim-pre-auth"
|
||||
| "ddma-eligibility-check";
|
||||
| "ddma-eligibility-check"
|
||||
| "deltains-eligibility-check"
|
||||
| "unitedsco-eligibility-check"
|
||||
| "cca-eligibility-check";
|
||||
|
||||
export interface SeleniumJobData {
|
||||
jobType: SeleniumJobType;
|
||||
|
||||
@@ -12,6 +12,9 @@ import documentsRoutes from "./documents";
|
||||
import patientDocumentsRoutes from "./patient-documents";
|
||||
import insuranceStatusRoutes from "./insuranceStatus";
|
||||
import insuranceStatusDdmaRoutes from "./insuranceStatusDDMA";
|
||||
import insuranceStatusDeltaInsRoutes from "./insuranceStatusDeltaIns";
|
||||
import insuranceStatusUnitedSCORoutes from "./insuranceStatusUnitedSCO";
|
||||
import insuranceStatusCCARoutes from "./insuranceStatusCCA";
|
||||
import paymentsRoutes from "./payments";
|
||||
import databaseManagementRoutes from "./database-management";
|
||||
import notificationsRoutes from "./notifications";
|
||||
@@ -36,6 +39,9 @@ router.use("/documents", documentsRoutes);
|
||||
router.use("/patient-documents", patientDocumentsRoutes);
|
||||
router.use("/insurance-status", insuranceStatusRoutes);
|
||||
router.use("/insurance-status-ddma", insuranceStatusDdmaRoutes);
|
||||
router.use("/insurance-status-deltains", insuranceStatusDeltaInsRoutes);
|
||||
router.use("/insurance-status-unitedsco", insuranceStatusUnitedSCORoutes);
|
||||
router.use("/insurance-status-cca", insuranceStatusCCARoutes);
|
||||
router.use("/payments", paymentsRoutes);
|
||||
router.use("/database-management", databaseManagementRoutes);
|
||||
router.use("/notifications", notificationsRoutes);
|
||||
|
||||
80
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal file
80
apps/Backend/src/routes/insuranceStatusCCA.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { io } from "../socket";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /cca-eligibility
|
||||
*
|
||||
* Enqueues a CCA (Commonwealth Care Alliance / ScionDental) eligibility check.
|
||||
* No OTP required — simple persistent session.
|
||||
*
|
||||
* Body:
|
||||
* data — patient + search fields (memberId, dateOfBirth, firstName, lastName)
|
||||
* socketId — socket.io client id for real-time updates
|
||||
*
|
||||
* Response: { status: "queued", jobId: "…" }
|
||||
*/
|
||||
router.post(
|
||||
"/cca-eligibility",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res.status(400).json({ error: "Missing eligibility data for selenium" });
|
||||
}
|
||||
if (!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;
|
||||
|
||||
// Fetch CCA credentials from DB
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
"CCA"
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error: "No credentials found for CCA. Please add them on the Settings page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
cca_username: credentials.username,
|
||||
cca_password: credentials.password,
|
||||
};
|
||||
|
||||
const socketId: string | undefined = req.body.socketId;
|
||||
|
||||
const jobId = enqueueSeleniumJob({
|
||||
jobType: "cca-eligibility-check",
|
||||
userId: req.user.id,
|
||||
socketId,
|
||||
enrichedPayload: enrichedData,
|
||||
insuranceId: String(rawData.memberId ?? "").trim(),
|
||||
formFirstName: rawData.firstName,
|
||||
formLastName: rawData.lastName,
|
||||
formDob: rawData.dateOfBirth,
|
||||
});
|
||||
|
||||
log("cca-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
|
||||
|
||||
return res.json({ status: "queued", jobId });
|
||||
} catch (err: any) {
|
||||
console.error("[cca-route] enqueue failed:", err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to enqueue CCA selenium job",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
134
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal file
134
apps/Backend/src/routes/insuranceStatusDeltaIns.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { forwardOtpToSeleniumDeltaInsAgent } from "../services/seleniumDeltaInsEligibilityClient";
|
||||
import { io } from "../socket";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) socket.emit(event, payload);
|
||||
} catch (err: any) {
|
||||
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /deltains-eligibility
|
||||
*
|
||||
* Enqueues a DeltaIns eligibility check in the shared InProcessQueue (concurrency=1).
|
||||
*
|
||||
* Body:
|
||||
* data — patient + search fields (memberId, dateOfBirth, …)
|
||||
* socketId — socket.io client id for real-time updates
|
||||
*
|
||||
* Response: { status: "queued", jobId: "…" }
|
||||
*
|
||||
* Real-time events emitted to socketId during job execution:
|
||||
* job:update { jobId, jobType, status: "active"|"completed"|"failed", … }
|
||||
* selenium:deltains_session_started { session_id, jobId }
|
||||
* selenium:otp_required { session_id, jobId, message }
|
||||
*/
|
||||
router.post(
|
||||
"/deltains-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?.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;
|
||||
|
||||
// Fetch DeltaIns credentials from DB
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
rawData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No insurance credentials found for Delta Dental Ins. Please add them on the Settings page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
deltains_username: credentials.username,
|
||||
deltains_password: credentials.password,
|
||||
};
|
||||
|
||||
const socketId: string | undefined = req.body.socketId;
|
||||
|
||||
const jobId = enqueueSeleniumJob({
|
||||
jobType: "deltains-eligibility-check",
|
||||
userId: req.user.id,
|
||||
socketId,
|
||||
enrichedPayload: enrichedData,
|
||||
insuranceId: String(rawData.memberId ?? "").trim(),
|
||||
formFirstName: rawData.firstName,
|
||||
formLastName: rawData.lastName,
|
||||
formDob: rawData.dateOfBirth,
|
||||
});
|
||||
|
||||
log("deltains-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
|
||||
|
||||
return res.json({ status: "queued", jobId });
|
||||
} catch (err: any) {
|
||||
console.error("[deltains-route] enqueue failed:", err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to enqueue DeltaIns selenium job",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /selenium/submit-otp
|
||||
*
|
||||
* Forwards the OTP entered by the user directly to the Python agent.
|
||||
* Side-channel — does NOT go through the queue.
|
||||
*
|
||||
* Body: { session_id, otp, socketId? }
|
||||
*/
|
||||
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 forwardOtpToSeleniumDeltaInsAgent(sessionId, otp);
|
||||
|
||||
emitSafe(socketId, "selenium:otp_submitted", {
|
||||
session_id: sessionId,
|
||||
result: r,
|
||||
});
|
||||
|
||||
return res.json(r);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"[deltains-route] submit-otp failed:",
|
||||
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;
|
||||
131
apps/Backend/src/routes/insuranceStatusUnitedSCO.ts
Normal file
131
apps/Backend/src/routes/insuranceStatusUnitedSCO.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { forwardOtpToSeleniumUnitedSCOAgent } from "../services/seleniumUnitedSCOEligibilityClient";
|
||||
import { io } from "../socket";
|
||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
function emitSafe(socketId: string | undefined, event: string, payload: any) {
|
||||
if (!socketId || !io) return;
|
||||
try {
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
if (socket) socket.emit(event, payload);
|
||||
} catch (err: any) {
|
||||
log("socket", "emit failed", { socketId, event, err: err?.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /unitedsco-eligibility
|
||||
*
|
||||
* Enqueues a Tufts SCO / UnitedHealthcare MA eligibility check (concurrency=1).
|
||||
*
|
||||
* Body:
|
||||
* data — patient + search fields (memberId, dateOfBirth, firstName, lastName, …)
|
||||
* socketId — socket.io client id for real-time updates
|
||||
*
|
||||
* Response: { status: "queued", jobId: "…" }
|
||||
*
|
||||
* Real-time socket events:
|
||||
* job:update { jobId, jobType, status: "active"|"completed"|"failed", … }
|
||||
* selenium:unitedsco_session_started { session_id, jobId }
|
||||
* selenium:otp_required { session_id, jobId, message }
|
||||
*/
|
||||
router.post(
|
||||
"/unitedsco-eligibility",
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.body.data) {
|
||||
return res.status(400).json({ error: "Missing eligibility data for selenium" });
|
||||
}
|
||||
if (!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;
|
||||
|
||||
// Fetch UnitedSCO credentials from DB
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||
req.user.id,
|
||||
rawData.insuranceSiteKey
|
||||
);
|
||||
if (!credentials) {
|
||||
return res.status(404).json({
|
||||
error:
|
||||
"No credentials found for Tufts SCO. Please add them on the Settings page.",
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedData = {
|
||||
...rawData,
|
||||
unitedscoUsername: credentials.username,
|
||||
unitedscoPassword: credentials.password,
|
||||
};
|
||||
|
||||
const socketId: string | undefined = req.body.socketId;
|
||||
|
||||
const jobId = enqueueSeleniumJob({
|
||||
jobType: "unitedsco-eligibility-check",
|
||||
userId: req.user.id,
|
||||
socketId,
|
||||
enrichedPayload: enrichedData,
|
||||
insuranceId: String(rawData.memberId ?? "").trim(),
|
||||
formFirstName: rawData.firstName,
|
||||
formLastName: rawData.lastName,
|
||||
formDob: rawData.dateOfBirth,
|
||||
});
|
||||
|
||||
log("unitedsco-route", "job enqueued", { jobId, insuranceId: rawData.memberId });
|
||||
|
||||
return res.json({ status: "queued", jobId });
|
||||
} catch (err: any) {
|
||||
console.error("[unitedsco-route] enqueue failed:", err);
|
||||
return res.status(500).json({
|
||||
error: err.message || "Failed to enqueue UnitedSCO selenium job",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /selenium/submit-otp
|
||||
* Side-channel OTP forwarding — does NOT go through the queue.
|
||||
* Body: { session_id, otp, socketId? }
|
||||
*/
|
||||
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 forwardOtpToSeleniumUnitedSCOAgent(sessionId, otp);
|
||||
|
||||
emitSafe(socketId, "selenium:otp_submitted", {
|
||||
session_id: sessionId,
|
||||
result: r,
|
||||
});
|
||||
|
||||
return res.json(r);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
"[unitedsco-route] submit-otp failed:",
|
||||
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;
|
||||
24
apps/Backend/src/services/seleniumCCAEligibilityClient.ts
Normal file
24
apps/Backend/src/services/seleniumCCAEligibilityClient.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from "axios";
|
||||
|
||||
const SELENIUM_BASE = process.env.SELENIUM_SERVICE_URL ?? "http://localhost:5002";
|
||||
|
||||
/**
|
||||
* POST /cca-eligibility
|
||||
* Returns { status: "started", session_id: "<uuid>" }
|
||||
*/
|
||||
export async function forwardToSeleniumCCAEligibilityAgent(
|
||||
data: Record<string, any>
|
||||
): Promise<{ status: string; session_id: string }> {
|
||||
const resp = await axios.post(`${SELENIUM_BASE}/cca-eligibility`, { data });
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /session/{sid}/status
|
||||
*/
|
||||
export async function getSeleniumCCASessionStatus(
|
||||
sessionId: string
|
||||
): Promise<Record<string, any>> {
|
||||
const resp = await axios.get(`${SELENIUM_BASE}/session/${sessionId}/status`);
|
||||
return resp.data;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from "axios";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
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(`[deltains-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(`[deltains-client] transient network error ${code} (attempt ${attempt})`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||
}
|
||||
return client.request(config);
|
||||
}
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumDeltaInsEligibilityAgent(data: any): Promise<any> {
|
||||
const payload = { data };
|
||||
const url = `/deltains-eligibility`;
|
||||
log("deltains-client", "POST deltains-eligibility", { url: SELENIUM_AGENT_BASE + url });
|
||||
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||
log("deltains-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 forwardOtpToSeleniumDeltaInsAgent(sessionId: string, otp: string): Promise<any> {
|
||||
const url = `/submit-otp`;
|
||||
log("deltains-client", "POST submit-otp", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||
const r = await requestWithRetries({ url, method: "POST", data: { session_id: sessionId, otp } }, 4);
|
||||
log("deltains-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 getSeleniumDeltaInsSessionStatus(sessionId: string): Promise<any> {
|
||||
const url = `/session/${sessionId}/status`;
|
||||
log("deltains-client", "GET session status", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||
log("deltains-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;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from "axios";
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
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(`[unitedsco-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(`[unitedsco-client] transient network error ${code} (attempt ${attempt})`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, baseBackoffMs * attempt));
|
||||
}
|
||||
return client.request(config);
|
||||
}
|
||||
|
||||
function log(tag: string, msg: string, ctx?: any) {
|
||||
console.log(`${new Date().toISOString()} [${tag}] ${msg}`, ctx ?? "");
|
||||
}
|
||||
|
||||
export async function forwardToSeleniumUnitedSCOEligibilityAgent(data: any): Promise<any> {
|
||||
const payload = { data };
|
||||
const url = `/unitedsco-eligibility`;
|
||||
log("unitedsco-client", "POST unitedsco-eligibility", { url: SELENIUM_AGENT_BASE + url });
|
||||
const r = await requestWithRetries({ url, method: "POST", data: payload }, 4);
|
||||
log("unitedsco-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 forwardOtpToSeleniumUnitedSCOAgent(sessionId: string, otp: string): Promise<any> {
|
||||
const url = `/submit-otp`;
|
||||
log("unitedsco-client", "POST submit-otp", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||
const r = await requestWithRetries({ url, method: "POST", data: { session_id: sessionId, otp } }, 4);
|
||||
log("unitedsco-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 getSeleniumUnitedSCOSessionStatus(sessionId: string): Promise<any> {
|
||||
const url = `/session/${sessionId}/status`;
|
||||
log("unitedsco-client", "GET session status", { url: SELENIUM_AGENT_BASE + url, sessionId });
|
||||
const r = await requestWithRetries({ url, method: "GET" }, 4);
|
||||
log("unitedsco-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;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, LoaderCircleIcon } 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/seleniumTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { socket } from "@/lib/socket";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface CCAEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function CCAEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: CCAEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = formatLocalDate(dateOfBirth);
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "CCA",
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Starting CCA eligibility check…",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-cca/cca-eligibility",
|
||||
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.error) {
|
||||
throw new Error(result.error || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const jobId: string = result.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "CCA job queued. Waiting for browser session…",
|
||||
})
|
||||
);
|
||||
|
||||
const onSessionStarted = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
sessionIdRef.current = data.session_id ?? null;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Browser session started. Running eligibility check…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("selenium:cca_session_started", onSessionStarted);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(safetyTimer);
|
||||
socket.off("selenium:cca_session_started", onSessionStarted);
|
||||
socket.off("job:update", onJobUpdate);
|
||||
}
|
||||
|
||||
const onJobUpdate = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
|
||||
if (data.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: data.message ?? "Selenium browser starting…",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (data.status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "success",
|
||||
message: "CCA eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "CCA eligibility complete",
|
||||
description: "Patient status was updated and the eligibility PDF was saved.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
const pdfId = data.result?.pdfFileId;
|
||||
if (pdfId) {
|
||||
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_cca_${memberId}.pdf`);
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
const msg = data.error ?? "CCA eligibility job failed.";
|
||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||
toast({ title: "CCA selenium error", description: msg, variant: "destructive" });
|
||||
}
|
||||
|
||||
setIsStarting(false);
|
||||
};
|
||||
|
||||
socket.on("job:update", onJobUpdate);
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
setIsStarting(false);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: "CCA job timed out waiting for completion.",
|
||||
})
|
||||
);
|
||||
}, 6 * 60 * 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("CCAEligibilityButton error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start CCA eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "CCA selenium error",
|
||||
description: err?.message || "Failed to start CCA eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
CCA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -408,7 +408,7 @@ export function DdmaEligibilityButton({
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta MA Eligibility
|
||||
Delta MA
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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/seleniumTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { socket } from "@/lib/socket";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
// ─── OTP Modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DeltaInsOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function DeltaInsOtpModal({ open, onClose, onSubmit, isSubmitting }: DeltaInsOtpModalProps) {
|
||||
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 Ins portal to your email.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deltains-otp">OTP</Label>
|
||||
<Input
|
||||
id="deltains-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 component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface DeltaInsEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function DeltaInsEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: DeltaInsEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = formatLocalDate(dateOfBirth);
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "DELTAINS",
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Starting Delta Ins eligibility check…",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-deltains/deltains-eligibility",
|
||||
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.error) {
|
||||
throw new Error(result.error || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const jobId: string = result.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Delta Ins job queued. Waiting for browser session to start…",
|
||||
})
|
||||
);
|
||||
|
||||
// Handler: Python agent started a browser session
|
||||
const onSessionStarted = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
sessionIdRef.current = data.session_id ?? null;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Browser session started. Waiting for OTP or result…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Handler: OTP required
|
||||
const onOtpRequired = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
if (data.session_id) sessionIdRef.current = data.session_id;
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "OTP required for Delta Dental Ins. Please enter the code from your email.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Handler: OTP accepted
|
||||
const onOtpSubmitted = (data: any) => {
|
||||
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "OTP submitted. Finishing Delta Ins eligibility check…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("selenium:deltains_session_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("selenium:otp_submitted", onOtpSubmitted);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(safetyTimer);
|
||||
socket.off("selenium:deltains_session_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("selenium:otp_submitted", onOtpSubmitted);
|
||||
socket.off("job:update", onJobUpdate);
|
||||
}
|
||||
|
||||
const onJobUpdate = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
|
||||
if (data.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: data.message ?? "Selenium browser starting…",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (data.status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "success",
|
||||
message: "Delta Ins eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Delta Ins eligibility complete",
|
||||
description: "Patient status was updated and the eligibility PDF was saved.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
const pdfId = data.result?.pdfFileId;
|
||||
if (pdfId) {
|
||||
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_deltains_${memberId}.pdf`);
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
const msg = data.error ?? "Delta Ins eligibility job failed.";
|
||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||
toast({ title: "Delta Ins selenium error", description: msg, variant: "destructive" });
|
||||
}
|
||||
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
};
|
||||
|
||||
socket.on("job:update", onJobUpdate);
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: "Delta Ins job timed out waiting for completion.",
|
||||
})
|
||||
);
|
||||
}, 6 * 60 * 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("DeltaInsEligibilityButton error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start Delta Ins eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Delta Ins selenium error",
|
||||
description: err?.message || "Failed to start Delta Ins eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description: "Cannot submit OTP — Delta Ins session ID is not available yet.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest("POST", "/api/insurance-status-deltains/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socket.id,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit OTP");
|
||||
}
|
||||
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={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Delta Ins Eligibility
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DeltaInsOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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/seleniumTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { socket } from "@/lib/socket";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
// ─── OTP Modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TuftsSCOOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function TuftsSCOOtpModal({ open, onClose, onSubmit, isSubmitting }: TuftsSCOOtpModalProps) {
|
||||
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 verification code sent to your phone or email to complete this check.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tufts-sco-otp">Verification Code</Label>
|
||||
<Input
|
||||
id="tufts-sco-otp"
|
||||
placeholder="Enter verification 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 Code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface TuftsSCOEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function TuftsSCOEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: TuftsSCOEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = formatLocalDate(dateOfBirth);
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "TUFTS_SCO",
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Starting Tufts SCO eligibility check…",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-unitedsco/unitedsco-eligibility",
|
||||
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.error) {
|
||||
throw new Error(result.error || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const jobId: string = result.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Tufts SCO job queued. Waiting for browser session…",
|
||||
})
|
||||
);
|
||||
|
||||
const onSessionStarted = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
sessionIdRef.current = data.session_id ?? null;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Browser session started. Waiting for verification code or result…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onOtpRequired = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
if (data.session_id) sessionIdRef.current = data.session_id;
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Verification code required. Please enter the code.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onOtpSubmitted = (data: any) => {
|
||||
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Code submitted. Finishing eligibility check…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("selenium:unitedsco_session_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("selenium:otp_submitted", onOtpSubmitted);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(safetyTimer);
|
||||
socket.off("selenium:unitedsco_session_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("selenium:otp_submitted", onOtpSubmitted);
|
||||
socket.off("job:update", onJobUpdate);
|
||||
}
|
||||
|
||||
const onJobUpdate = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
|
||||
if (data.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: data.message ?? "Selenium browser starting…",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (data.status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "success",
|
||||
message: "Tufts SCO eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Tufts SCO eligibility complete",
|
||||
description: "Patient status was updated and the eligibility PDF was saved.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
const pdfId = data.result?.pdfFileId;
|
||||
if (pdfId) {
|
||||
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
const msg = data.error ?? "Tufts SCO eligibility job failed.";
|
||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||
toast({ title: "Tufts SCO selenium error", description: msg, variant: "destructive" });
|
||||
}
|
||||
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
};
|
||||
|
||||
socket.on("job:update", onJobUpdate);
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: "Tufts SCO job timed out waiting for completion.",
|
||||
})
|
||||
);
|
||||
}, 6 * 60 * 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("TuftsSCOEligibilityButton error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start Tufts SCO eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "Tufts SCO selenium error",
|
||||
description: err?.message || "Failed to start Tufts SCO eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description: "Cannot submit code — session ID is not available yet.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest("POST", "/api/insurance-status-unitedsco/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socket.id,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit code");
|
||||
}
|
||||
setOtpModalOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error("handleSubmitOtp error:", err);
|
||||
toast({
|
||||
title: "Failed to submit code",
|
||||
description: err?.message || "Error forwarding code to selenium agent",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<TuftsSCOOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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/seleniumTaskSlice";
|
||||
import { formatLocalDate } from "@/utils/dateUtils";
|
||||
import { socket } from "@/lib/socket";
|
||||
import { QK_PATIENTS_BASE } from "@/components/patients/patient-table";
|
||||
|
||||
// ─── OTP Modal ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UnitedSCOOtpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (otp: string) => Promise<void> | void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function UnitedSCOOtpModal({ open, onClose, onSubmit, isSubmitting }: UnitedSCOOtpModalProps) {
|
||||
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 verification code sent to your phone or email to complete this check.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="united-sco-otp">Verification Code</Label>
|
||||
<Input
|
||||
id="united-sco-otp"
|
||||
placeholder="Enter verification 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 Code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface UnitedSCOEligibilityButtonProps {
|
||||
memberId: string;
|
||||
dateOfBirth: Date | null;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isFormIncomplete: boolean;
|
||||
onPdfReady: (pdfId: number, fallbackFilename: string | null) => void;
|
||||
}
|
||||
|
||||
export function UnitedSCOEligibilityButton({
|
||||
memberId,
|
||||
dateOfBirth,
|
||||
firstName,
|
||||
lastName,
|
||||
isFormIncomplete,
|
||||
onPdfReady,
|
||||
}: UnitedSCOEligibilityButtonProps) {
|
||||
const { toast } = useToast();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const [otpModalOpen, setOtpModalOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [isSubmittingOtp, setIsSubmittingOtp] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!memberId || !dateOfBirth) {
|
||||
toast({
|
||||
title: "Missing fields",
|
||||
description: "Member ID and Date of Birth are required.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedDob = formatLocalDate(dateOfBirth);
|
||||
const payload = {
|
||||
memberId,
|
||||
dateOfBirth: formattedDob,
|
||||
firstName,
|
||||
lastName,
|
||||
insuranceSiteKey: "UNITED_SCO",
|
||||
};
|
||||
|
||||
setIsStarting(true);
|
||||
|
||||
try {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Starting United SCO eligibility check…",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await apiRequest(
|
||||
"POST",
|
||||
"/api/insurance-status-unitedsco/unitedsco-eligibility",
|
||||
{ data: JSON.stringify(payload), socketId: socket.id }
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.error) {
|
||||
throw new Error(result.error || `Server error (${response.status})`);
|
||||
}
|
||||
|
||||
const jobId: string = result.jobId;
|
||||
if (!jobId) throw new Error("No jobId returned from server");
|
||||
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "United SCO job queued. Waiting for browser session…",
|
||||
})
|
||||
);
|
||||
|
||||
const onSessionStarted = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
sessionIdRef.current = data.session_id ?? null;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Browser session started. Waiting for verification code or result…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onOtpRequired = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
if (data.session_id) sessionIdRef.current = data.session_id;
|
||||
setOtpModalOpen(true);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Verification code required. Please enter the code.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onOtpSubmitted = (data: any) => {
|
||||
if (data?.session_id && data.session_id !== sessionIdRef.current) return;
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: "Code submitted. Finishing eligibility check…",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
socket.on("selenium:unitedsco_session_started", onSessionStarted);
|
||||
socket.on("selenium:otp_required", onOtpRequired);
|
||||
socket.on("selenium:otp_submitted", onOtpSubmitted);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(safetyTimer);
|
||||
socket.off("selenium:unitedsco_session_started", onSessionStarted);
|
||||
socket.off("selenium:otp_required", onOtpRequired);
|
||||
socket.off("selenium:otp_submitted", onOtpSubmitted);
|
||||
socket.off("job:update", onJobUpdate);
|
||||
}
|
||||
|
||||
const onJobUpdate = (data: any) => {
|
||||
if (String(data?.jobId) !== String(jobId)) return;
|
||||
|
||||
if (data.status === "active") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "pending",
|
||||
message: data.message ?? "Selenium browser starting…",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
if (data.status === "completed") {
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "success",
|
||||
message: "United SCO eligibility updated and PDF attached to patient documents.",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "United SCO eligibility complete",
|
||||
description: "Patient status was updated and the eligibility PDF was saved.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: QK_PATIENTS_BASE });
|
||||
const pdfId = data.result?.pdfFileId;
|
||||
if (pdfId) {
|
||||
onPdfReady(Number(pdfId), data.result?.pdfFilename ?? `eligibility_unitedsco_${memberId}.pdf`);
|
||||
}
|
||||
} else if (data.status === "failed") {
|
||||
const msg = data.error ?? "United SCO eligibility job failed.";
|
||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "error", message: msg }));
|
||||
toast({ title: "United SCO selenium error", description: msg, variant: "destructive" });
|
||||
}
|
||||
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
};
|
||||
|
||||
socket.on("job:update", onJobUpdate);
|
||||
|
||||
const safetyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
setIsStarting(false);
|
||||
setOtpModalOpen(false);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: "United SCO job timed out waiting for completion.",
|
||||
})
|
||||
);
|
||||
}, 6 * 60 * 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("UnitedSCOEligibilityButton error:", err);
|
||||
dispatch(
|
||||
setTaskStatus({
|
||||
key: "eligibilityCheck",
|
||||
status: "error",
|
||||
message: err?.message || "Failed to start United SCO eligibility",
|
||||
})
|
||||
);
|
||||
toast({
|
||||
title: "United SCO selenium error",
|
||||
description: err?.message || "Failed to start United SCO eligibility",
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitOtp = async (otp: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
toast({
|
||||
title: "Session not ready",
|
||||
description: "Cannot submit code — session ID is not available yet.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingOtp(true);
|
||||
const resp = await apiRequest("POST", "/api/insurance-status-unitedsco/selenium/submit-otp", {
|
||||
session_id: sessionId,
|
||||
otp,
|
||||
socketId: socket.id,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.error || "Failed to submit code");
|
||||
}
|
||||
setOtpModalOpen(false);
|
||||
} catch (err: any) {
|
||||
console.error("handleSubmitOtp error:", err);
|
||||
toast({
|
||||
title: "Failed to submit code",
|
||||
description: err?.message || "Error forwarding code to selenium agent",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="default"
|
||||
disabled={isFormIncomplete || isStarting}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United SCO
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<UnitedSCOOtpModal
|
||||
open={otpModalOpen}
|
||||
onClose={() => setOtpModalOpen(false)}
|
||||
onSubmit={handleSubmitOtp}
|
||||
isSubmitting={isSubmittingOtp}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,10 @@ 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";
|
||||
import { DeltaInsEligibilityButton } from "@/components/insurance-status/deltains-button-modal";
|
||||
import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-sco-button-modal";
|
||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
||||
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
||||
|
||||
export default function InsuranceStatusPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -622,44 +626,68 @@ export default function InsuranceStatusPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
</Button>
|
||||
<DeltaInsEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_deltains_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
CCA
|
||||
</Button>
|
||||
<CCAEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_cca_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<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" />
|
||||
Tufts SCO/SWH/Navi/Mass Gen
|
||||
</Button>
|
||||
<TuftsSCOEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
United SCO
|
||||
</Button>
|
||||
<UnitedSCOEligibilityButton
|
||||
memberId={memberId}
|
||||
dateOfBirth={dateOfBirth}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
isFormIncomplete={isFormIncomplete}
|
||||
onPdfReady={(pdfId, fallbackFilename) => {
|
||||
setPreviewPdfId(pdfId);
|
||||
setPreviewFallbackFilename(
|
||||
fallbackFilename ?? `eligibility_unitedsco_${memberId}.pdf`,
|
||||
);
|
||||
setPreviewOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -689,7 +717,14 @@ export default function InsuranceStatusPage() {
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Altus
|
||||
</Button>
|
||||
<div /> {/* filler cell to keep grid shape */}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
disabled={isFormIncomplete}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Metlife Dental
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -11,19 +11,27 @@ from selenium_preAuthWorker import AutomationMassHealthPreAuth
|
||||
import os
|
||||
import time
|
||||
import helpers_ddma_eligibility as hddma
|
||||
import helpers_deltains_eligibility as hdeltains
|
||||
import helpers_unitedsco_eligibility as hunitedsco
|
||||
import helpers_cca_eligibility as hcca
|
||||
|
||||
# Import startup session-clear functions
|
||||
from ddma_browser_manager import clear_ddma_session_on_startup
|
||||
from deltains_browser_manager import clear_deltains_session_on_startup
|
||||
from unitedsco_browser_manager import clear_unitedsco_session_on_startup
|
||||
from cca_browser_manager import clear_cca_session_on_startup
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
# Clear DDMA session on startup so fresh login is required after PC restart.
|
||||
# Device trust tokens are preserved so OTP is still skipped after first login.
|
||||
# Clear sessions on startup so fresh login is required after PC restart.
|
||||
print("=" * 50)
|
||||
print("SELENIUM AGENT STARTING - CLEARING DDMA SESSION")
|
||||
print("SELENIUM AGENT STARTING - CLEARING SESSIONS")
|
||||
print("=" * 50)
|
||||
clear_ddma_session_on_startup()
|
||||
clear_deltains_session_on_startup()
|
||||
clear_unitedsco_session_on_startup()
|
||||
clear_cca_session_on_startup()
|
||||
print("=" * 50)
|
||||
print("SESSION CLEAR COMPLETE")
|
||||
print("=" * 50)
|
||||
@@ -251,11 +259,134 @@ async def ddma_eligibility(request: Request):
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _deltains_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for DeltaIns — acquires semaphore, updates counters."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hdeltains.start_deltains_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/deltains-eligibility")
|
||||
async def deltains_eligibility(request: Request):
|
||||
"""
|
||||
Starts a DeltaIns eligibility session in the background.
|
||||
Body: { "data": { ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
sid = hdeltains.make_session_entry()
|
||||
hdeltains.sessions[sid]["type"] = "deltains_eligibility"
|
||||
hdeltains.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_deltains_worker_wrapper(
|
||||
sid, data,
|
||||
url="https://www.deltadentalins.com/ciam/login?TARGET=%2Fprovider-tools%2Fv2"
|
||||
))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _unitedsco_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for UnitedSCO — acquires semaphore, updates counters."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hunitedsco.start_unitedsco_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/unitedsco-eligibility")
|
||||
async def unitedsco_eligibility(request: Request):
|
||||
"""
|
||||
Starts a UnitedSCO eligibility session in the background.
|
||||
Body: { "data": { ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
sid = hunitedsco.make_session_entry()
|
||||
hunitedsco.sessions[sid]["type"] = "unitedsco_eligibility"
|
||||
hunitedsco.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_unitedsco_worker_wrapper(
|
||||
sid, data,
|
||||
url="https://app.dentalhub.com/app/login"
|
||||
))
|
||||
|
||||
return {"status": "started", "session_id": sid}
|
||||
|
||||
|
||||
async def _cca_worker_wrapper(sid: str, data: dict, url: str):
|
||||
"""Background worker for CCA — acquires semaphore, updates counters. No OTP."""
|
||||
global active_jobs, waiting_jobs
|
||||
async with semaphore:
|
||||
async with lock:
|
||||
waiting_jobs -= 1
|
||||
active_jobs += 1
|
||||
try:
|
||||
await hcca.start_cca_run(sid, data, url)
|
||||
finally:
|
||||
async with lock:
|
||||
active_jobs -= 1
|
||||
|
||||
|
||||
@app.post("/cca-eligibility")
|
||||
async def cca_eligibility(request: Request):
|
||||
"""
|
||||
Starts a CCA eligibility session in the background (no OTP).
|
||||
Body: { "data": { ... } }
|
||||
Returns: { status: "started", session_id: "<uuid>" }
|
||||
"""
|
||||
global waiting_jobs
|
||||
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
sid = hcca.make_session_entry()
|
||||
hcca.sessions[sid]["type"] = "cca_eligibility"
|
||||
hcca.sessions[sid]["last_activity"] = time.time()
|
||||
|
||||
async with lock:
|
||||
waiting_jobs += 1
|
||||
|
||||
asyncio.create_task(_cca_worker_wrapper(
|
||||
sid, data,
|
||||
url="https://pwp.sciondental.com/PWP/Landing"
|
||||
))
|
||||
|
||||
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.
|
||||
Tries each session store in order (CCA has no OTP but included for completeness).
|
||||
"""
|
||||
body = await request.json()
|
||||
sid = body.get("session_id")
|
||||
@@ -263,7 +394,16 @@ async def submit_otp(request: Request):
|
||||
if not sid or not otp:
|
||||
raise HTTPException(status_code=400, detail="session_id and otp required")
|
||||
|
||||
res = hddma.submit_otp(sid, otp)
|
||||
# Try each session store in order
|
||||
if sid in hddma.sessions:
|
||||
res = hddma.submit_otp(sid, otp)
|
||||
elif sid in hdeltains.sessions:
|
||||
res = hdeltains.submit_otp(sid, otp)
|
||||
elif sid in hunitedsco.sessions:
|
||||
res = hunitedsco.submit_otp(sid, otp)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
|
||||
if res.get("status") == "error":
|
||||
raise HTTPException(status_code=400, detail=res.get("message"))
|
||||
return res
|
||||
@@ -271,7 +411,17 @@ async def submit_otp(request: Request):
|
||||
|
||||
@app.get("/session/{sid}/status")
|
||||
async def session_status(sid: str):
|
||||
s = hddma.get_session_status(sid)
|
||||
# Try each session store in order
|
||||
if sid in hddma.sessions:
|
||||
s = hddma.get_session_status(sid)
|
||||
elif sid in hdeltains.sessions:
|
||||
s = hdeltains.get_session_status(sid)
|
||||
elif sid in hunitedsco.sessions:
|
||||
s = hunitedsco.get_session_status(sid)
|
||||
elif sid in hcca.sessions:
|
||||
s = hcca.get_session_status(sid)
|
||||
else:
|
||||
s = {"status": "not_found"}
|
||||
if s.get("status") == "not_found":
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return s
|
||||
@@ -281,10 +431,7 @@ async def session_status(sid: str):
|
||||
|
||||
@app.post("/clear-ddma-session")
|
||||
async def clear_ddma_session_endpoint():
|
||||
"""
|
||||
Clears the DDMA browser session (cookies + cached credentials).
|
||||
Call this when DDMA credentials are deleted or changed.
|
||||
"""
|
||||
"""Clears the DDMA browser session. Call when credentials are deleted or changed."""
|
||||
try:
|
||||
clear_ddma_session_on_startup()
|
||||
return {"status": "success", "message": "DDMA session cleared"}
|
||||
@@ -292,6 +439,36 @@ async def clear_ddma_session_endpoint():
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-deltains-session")
|
||||
async def clear_deltains_session_endpoint():
|
||||
"""Clears the DeltaIns browser session. Call when credentials are deleted or changed."""
|
||||
try:
|
||||
clear_deltains_session_on_startup()
|
||||
return {"status": "success", "message": "DeltaIns session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-unitedsco-session")
|
||||
async def clear_unitedsco_session_endpoint():
|
||||
"""Clears the UnitedSCO browser session. Call when credentials are deleted or changed."""
|
||||
try:
|
||||
clear_unitedsco_session_on_startup()
|
||||
return {"status": "success", "message": "UnitedSCO session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/clear-cca-session")
|
||||
async def clear_cca_session_endpoint():
|
||||
"""Clears the CCA browser session. Call when credentials are deleted or changed."""
|
||||
try:
|
||||
clear_cca_session_on_startup()
|
||||
return {"status": "success", "message": "CCA session cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# ✅ Health Check Endpoint
|
||||
@app.get("/")
|
||||
async def health_check():
|
||||
|
||||
2
apps/SeleniumServiceold/.env.example
Executable file
2
apps/SeleniumServiceold/.env.example
Executable file
@@ -0,0 +1,2 @@
|
||||
HOST=localhost
|
||||
PORT=5002
|
||||
2
apps/SeleniumServiceold/.gitignore
vendored
Executable file
2
apps/SeleniumServiceold/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
/__pycache__
|
||||
618
apps/SeleniumServiceold/PDF_To_Test/sample1.pdf
Normal file
618
apps/SeleniumServiceold/PDF_To_Test/sample1.pdf
Normal file
@@ -0,0 +1,618 @@
|
||||
%PDF-1.3
|
||||
%<25>쏢
|
||||
1 0 obj
|
||||
<</Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<</Type /Pages
|
||||
/Count 1
|
||||
/Kids [11 0 R]
|
||||
>>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<</Producer (Persits Software AspPDF - www.persits.com)
|
||||
/Title (Member Eligibility)
|
||||
/Creator (MassDHP)
|
||||
>>
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<</Type /Font
|
||||
/Subtype /Type0
|
||||
/BaseFont /PBEWKK+Arial
|
||||
/Name /F1
|
||||
/Encoding /Identity-H
|
||||
/DescendantFonts [5 0 R]
|
||||
/ToUnicode 9 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
5 0 obj
|
||||
<</Type /Font
|
||||
/Subtype /CIDFontType2
|
||||
/BaseFont /PBEWKK+Arial
|
||||
/CIDSystemInfo 6 0 R
|
||||
/CIDToGIDMap /Identity
|
||||
/FontDescriptor 7 0 R
|
||||
/W [0 [750 0 277 277 277 354 556 556 889 666 190 333 333 389 583 277 333 277 277 556
|
||||
556 556 556 556 556 556 556 556 556 277 277 583 583 583 556 1015 666 666 722 722
|
||||
666 610 777 722 277 500 666 556 833 722 777 666 777 722 666 610 722 666 943 666
|
||||
666 610 277 277 277 469 556 333 556 556 500 556 556 277 556 556 222 222 500 222
|
||||
833 556 556 556 556 333 500 277 556 500 722 500 500 500 333 259 333 583 666 666
|
||||
722 666 722 777 722 556 556 556 556 556 556 500 556 556 556 556 277 277 277 277
|
||||
556 556 556 556 556 556 556 556 556 556 556 399 556 556 556 350 537 610 736 736
|
||||
1000 333 333 548 1000 777 712 548 548 548 556 576 494 712 823 548 273 370 365 768
|
||||
889 610 610 333 583 548 556 548 611 556 556 1000 666 666 777 1000 943 556 1000 333
|
||||
333 222 222 548 494 500 666 166 556 333 333 500 500 556 277 222 333 1000 666 666
|
||||
666 666 666 277 277 277 277 777 777 777 722 722 722 277 333 333 333 333 333 333
|
||||
333 333 333 333 556 222 666 500 610 500 259 722 556 666 500 666 556 583 583 333
|
||||
333 333 833 833 833 556 777 556 277 666 500 722 500 722 500 556 552 333 666 556
|
||||
666 556 722 614 722 666 556 666 556 556 222 556 291 556 333 722 556 722 556 777
|
||||
556 722 333 722 333 666 500 610 277 610 375 722 556 722 556 610 500 610 500 550
|
||||
777 797 578 556 445 617 395 648 552 500 364 1093 1000 500 1000 500 1000 500 500 979
|
||||
718 583 604 583 604 604 708 625 708 708 708 708 708 708 708 708 708 708 708 708
|
||||
708 708 708 708 708 708 708 708 708 708 708 708 708 708 708 708 708 708 708 708
|
||||
708 708 708 708 708 708 708 708 708 708 708 708 708 729 604 1000 989 989 989 989
|
||||
604 604 604 1020 1052 916 750 750 531 656 593 510 500 750 734 443 604 187 354 885
|
||||
323 604 354 354 604 354 666 556 722 500 722 500 666 556 666 556 666 556 777 556
|
||||
777 556 777 556 722 556 722 556 277 277 277 277 277 277 277 222 500 222 666 500
|
||||
500 556 222 722 556 723 556 777 556 777 556 722 333 666 500 610 277 722 556 722
|
||||
556 722 556 722 556 943 722 666 500 222 666 556 1000 889 777 610 277 943 722 943
|
||||
722 943 722 666 500 222 333 556 600 833 833 833 833 333 333 333 333 667 784 837
|
||||
383 774 855 752 222 666 666 667 666 610 722 277 666 667 833 722 649 777 722 666
|
||||
618 610 666 666 835 747 277 666 578 445 556 222 546 575 500 440 556 556 222 500
|
||||
500 576 500 447 556 568 481 546 524 712 780 222 546 556 546 780 667 864 541 718
|
||||
666 277 277 500 1057 1010 854 582 635 718 666 656 666 541 677 666 923 604 718 718
|
||||
582 656 833 722 777 718 666 722 610 635 760 666 739 666 916 937 791 885 656 718
|
||||
1010 722 556 572 531 364 583 556 668 458 558 558 437 583 687 552 556 541 556 500
|
||||
458 500 822 500 572 520 802 822 625 718 520 510 750 541 556 556 364 510 500 222
|
||||
277 222 906 812 556 437 500 552 488 411 1000 1072 689 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 382 0 274 0 0 277 562 541 398 508 602 246 382 598
|
||||
589 246 509 460 462 598 601 246 352 574 529 566 546 461 478 549 509 694 642 493
|
||||
493 493 235 416 815 246 509 509 462 462 535 694 694 694 694 562 562 562 541 398
|
||||
508 602 286 411 589 286 509 460 462 601 352 574 566 546 478 549 509 694 642 246
|
||||
541 460 546 575 0 0 0 0 318 318 356 412 207 0 0 0 0 0 0 0
|
||||
0 525 525 525 525 525 525 525 525 525 525 525 318 525 750 750 282 750 525 525
|
||||
525 750 750 750 750 750 0 750 750 750 750 750 750 750 750 638 750 750 750 713
|
||||
713 244 244 750 750 750 750 562 525 529 529 488 488 812 932 394 514 812 932 394
|
||||
514 638 588 375 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 0
|
||||
0 0 0 0 750 750 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 556 1000 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750
|
||||
750 750 750 750 750 750 750 750 750 750 750 750 318 318 750 616 412 207 229 207
|
||||
229 432 432 207 229 638 588 244 244 207 229 713 713 244 244 282 375 713 713 244
|
||||
244 713 713 244 244 562 525 529 529 562 525 529 529 562 525 529 529 337 337 337
|
||||
337 488 488 488 488 821 821 530 530 821 821 530 530 1098 1098 846 846 1098 1098 846
|
||||
846 581 581 581 581 581 581 581 581 543 450 525 394 543 450 525 394 788 788 267
|
||||
262 581 581 267 262 601 601 394 394 506 506 207 207 337 337 394 394 525 525 244
|
||||
244 282 375 450 394 432 432 638 588 638 588 244 244 543 600 543 600 543 600 543
|
||||
600 750 750 0 0 750 750 750 0 0 750 750 0 0 750 750 750 0 0 0
|
||||
0 0 0 750 0 0 750 750 750 750 750 750 750 750 750 750 750 750 750 750
|
||||
750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750
|
||||
750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750
|
||||
318 318 318 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750 750
|
||||
750 750 750 750 750 750 750 125 1000 2000 857 655 854 669 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 513 833 833 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 222 666 556 666 556 666 556 666 556 666 556 666 556
|
||||
666 556 666 556 666 556 666 556 666 556 666 556 666 556 666 556 666 556 666 556
|
||||
666 556 666 556 666 556 666 556 277 222 277 222 777 556 777 556 777 556 777 556
|
||||
777 556 777 556 777 556 857 655 857 655 857 655 857 655 857 655 722 556 722 556
|
||||
854 669 854 669 854 669 854 669 854 669 666 500 666 500 666 500 666 556 277 222
|
||||
777 556 722 556 722 556 722 556 722 556 722 556 0 0 0 0 541 364 923 668
|
||||
582 437 582 437 722 552 556 500 556 500 666 500 666 520 666 556 752 556 777 556
|
||||
713 244 267 262 581 244 244 244 244 244 244 269 0 0 333 333 0 0 0 0
|
||||
207 229 207 229 207 229 207 229 432 432 432 432 638 588 713 713 244 244 713 713
|
||||
244 244 713 713 244 244 713 713 244 244 713 713 244 244 713 713 244 244 713 713
|
||||
244 244 562 525 529 529 562 525 529 529 562 525 529 529 562 525 529 529 562 525
|
||||
529 529 562 525 529 529 337 337 337 337 337 337 337 337 337 337 337 337 337 337
|
||||
337 337 337 337 488 488 488 488 488 488 488 488 488 488 488 488 488 488 488 488
|
||||
821 821 530 530 821 821 530 530 821 821 530 530 1098 1098 846 846 1098 1098 846 846
|
||||
581 581 543 450 525 394 788 788 788 267 262 788 788 267 262 788 788 267 262 788
|
||||
788 267 262 788 788 267 262 581 581 581 581 1155 1155 906 906 812 932 394 514 601
|
||||
601 394 394 601 601 394 394 601 601 394 394 812 932 394 514 812 932 394 514 812
|
||||
932 394 514 812 932 394 514 812 932 394 514 506 506 207 207 506 506 207 207 506
|
||||
506 207 207 506 506 207 207 525 525 244 244 525 525 525 525 525 525 244 244 525
|
||||
525 562 525 529 529 282 375 387 387 387 432 432 432 432 432 432 432 432 432 432
|
||||
432 432 432 432 432 432 638 588 638 588 244 244 432 432 638 588 244 244 638 588
|
||||
812 812 812 812 207 0 0 0 0 0 0 0 1123 1084 0 0 0 0 0 0
|
||||
193 370 0 0 600 0 0 0 821 821 530 530 1098 1098 846 846 543 450 525 394
|
||||
412 337 282 244 320 244 244 244 244 244 812 932 246 0 341 493 543 600 543 600
|
||||
543 600 543 600 543 600 543 600 543 600 525 525 543 600 556 758 656 556 656 556
|
||||
722 722 500 722 809 656 556 556 666 604 610 777 624 880 222 277 666 500 222 500
|
||||
890 722 556 777 868 667 754 556 666 666 500 618 380 277 610 277 610 747 722 772
|
||||
500 610 500 610 610 544 544 556 556 458 486 556 259 413 583 277 1333 1222 1048 1062
|
||||
833 451 1222 944 770 556 666 556 0 666 556 1000 889 777 556 777 556 666 500 777
|
||||
556 777 556 610 544 222 1333 1222 1048 777 556 1034 618 722 556 666 556 666 556 666
|
||||
556 666 556 277 277 277 277 777 556 777 556 722 333 722 333 722 556 722 556 666
|
||||
500 610 277 544 436 722 556 706 604 565 610 500 666 556 666 556 777 556 0 777
|
||||
556 777 556 777 556 666 500 556 556 556 556 500 500 556 556 556 738 458 458 631
|
||||
507 277 556 556 558 500 616 556 556 556 222 222 355 327 303 222 571 833 833 833
|
||||
556 556 552 556 790 780 549 333 333 333 333 333 333 333 541 541 500 222 259 222
|
||||
349 277 277 556 568 546 500 722 500 519 500 541 544 544 500 500 500 500 777 531
|
||||
507 558 552 397 500 403 556 500 500 964 906 1005 712 429 718 763 661 632 485 527
|
||||
383 383 159 239 239 239 364 480 320 190 354 222 222 222 333 333 348 348 583 583
|
||||
583 583 333 333 333 333 333 333 333 277 277 333 333 333 333 333 333 333 333 322
|
||||
157 339 328 348 382 382 382 382 382 333 333 333 333 333 542 542 542 542 542 542
|
||||
542 542 542 382 542 542 542 542 542 382 542 542 542 542 542 382 542 542 542 542
|
||||
542 382 542 542 542 542 542 382 542 542 542 542 542 542 542 542 542 382 542 542
|
||||
542 542 542 382 542 542 542 542 542 382 542 542 542 542 542 382 542 542 542 542
|
||||
542 382 542 542 542 542 542 542 542 542 542 382 542 542 542 542 542 382 542 542
|
||||
542 542 542 382 542 542 542 542 542 382 542 542 542 542 542 382 542 542 542 542
|
||||
542 542 542 542 542 382 542 542 542 542 542 382 542 542 542 542 542 382 542 542
|
||||
542 542 542 382 542 542 542 542 542 382 542 542 542 542 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 333 333 333 575 546 772 958 772 560 780 601 777 556 722 500
|
||||
610 403 624 529 756 576 890 833 674 556 673 500 666 666 609 596 736 553 463 409
|
||||
601 572 500 222 777 441 441 666 718 556 558 1337 624 777 612 949 713 667 500 897
|
||||
695 828 685 1053 867 604 458 796 688 777 556 803 630 803 630 1074 896 833 612 1190
|
||||
851 0 1337 624 722 500 503 0 0 0 0 0 0 718 558 656 520 666 556 670
|
||||
548 604 458 582 437 741 535 879 647 1136 870 752 520 722 500 610 458 925 690 666
|
||||
520 861 666 861 666 277 923 668 667 550 656 583 722 552 722 552 666 520 833 687
|
||||
333 666 556 666 556 1000 889 666 556 752 556 923 668 604 458 604 544 718 558 718
|
||||
558 777 556 777 556 718 510 635 500 635 500 635 500 666 520 885 718 656 556 968
|
||||
876 956 815 662 509 970 909 1034 878 777 558 746 665 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 666 556 666 556 666 556 666 556 722
|
||||
500 722 556 722 556 722 556 722 556 722 556 666 556 666 556 666 556 666 556 666
|
||||
556 610 277 777 556 722 556 722 556 722 556 722 556 722 556 277 222 277 277 666
|
||||
500 666 500 666 500 556 222 556 222 556 222 556 222 833 833 833 833 833 833 722
|
||||
556 722 556 722 556 722 556 777 556 777 556 777 556 777 556 666 556 666 556 722
|
||||
333 722 333 722 333 722 333 666 500 666 500 666 500 666 500 666 500 610 277 610
|
||||
277 610 277 610 277 722 556 722 556 722 556 722 556 722 556 666 500 666 500 943
|
||||
722 943 722 666 500 666 500 666 500 610 500 610 500 610 500 556 277 722 500 556
|
||||
222 578 578 578 578 578 578 578 578 666 666 813 813 813 813 813 813 445 445 445
|
||||
445 445 445 764 764 927 927 927 927 556 556 556 556 556 556 556 556 819 819 1015
|
||||
1015 1015 1015 1015 1015 222 222 222 222 222 222 222 222 375 375 570 570 570 570 570
|
||||
570 556 556 556 556 556 556 826 826 1021 1021 973 973 546 546 546 546 546 546 546
|
||||
546 813 959 1008 959 780 780 780 780 780 780 780 780 796 796 991 991 942 942 942
|
||||
942 578 578 445 445 556 556 222 222 556 556 546 546 780 780 578 578 578 578 578
|
||||
578 578 578 666 666 813 813 813 813 813 813 556 556 556 556 556 556 556 556 819
|
||||
819 1015 1015 1015 1015 1015 1015 780 780 780 780 780 780 780 780 796 796 991 991 942
|
||||
942 942 942 578 578 578 578 578 578 578 666 666 666 666 666 333 333 333 333 333
|
||||
556 556 556 556 556 813 813 868 868 722 333 333 333 222 222 222 222 222 222 277
|
||||
277 424 424 333 333 333 546 546 546 546 568 568 546 546 666 666 862 886 764 333
|
||||
333 333 780 780 780 780 780 924 826 894 796 747 333 333 556 722 722 833 722 1164
|
||||
943 666 610 1000 500 594 0 0 0 0 222 222 520 666 681 349 684 367 687 687
|
||||
333 333 333 333 333 333 333 333 333 277 333 333 333 333 397 397 333 0 0 0
|
||||
0 0 0 0 0 0 0 0 666 556 496 747 889 531 500 551 551 489 458 222
|
||||
422 500 401 687 558 556 500 608 608 608 943 457 556 556 520 541 541 458 546 596
|
||||
733 596 500 722 500 458 427 606 364 500 541 520 712 583 453 663 414 414 449 410
|
||||
410 496 428 166 314 424 351 510 430 429 511 382 418 451 432 429 622 372 372 376
|
||||
599 377 377 371 371 318 318 376 157 338 572 382 377 354 377 377 377 219 382 407
|
||||
572 321 390 385 321 377 439 343 157 239 382 321 385 321 379 439 343 936 1299 438
|
||||
1272 656 238 543 0 0 0 0 0 0 0 0 0 337 337 488 488 450 394 450
|
||||
394 709 654 748 607 609 745 655 789 583 0 0 0 556 333 354 207 207 207 207
|
||||
792 1221 500 1000 500 1000 333 250 166 556 277 200 83 0 736 722 833 687 908 886
|
||||
886 666 722 500 556 610 500 500 580 0 0 0 0 0 568 722 722 722 541 364
|
||||
0 0 0 352 0 262 289 0 0 0 0 0 0 0 713 713 244 244 713 713
|
||||
244 244 713 713 244 244 713 713 244 244 713 713 244 244 713 713 244 244 713 713
|
||||
244 244 562 525 529 529 562 525 529 529 337 337 337 337 488 488 821 821 530 530
|
||||
543 450 525 394 543 450 525 394 543 450 525 394 788 788 267 262 788 788 267 262
|
||||
812 932 394 514 812 932 394 514 812 932 394 514 337 337 394 394 337 337 394 394
|
||||
525 525 244 244 525 525 244 244 525 525 244 244 506 506 207 207 488 488 488 488
|
||||
821 821 530 530 556 556 277 833 556 556 333 333 500 277 500 556 380 556 785 222
|
||||
222 556 546 568 556 556 277 712 500 222 833 556 556 333 500 386 500 500 500 556
|
||||
556 556 556 458 458 650 222 500 222 556 544 376 354 348 373 318 229 229 376 383
|
||||
157 157 157 157 270 157 157 274 571 571 382 382 381 377 375 339 157 219 382 387
|
||||
377 353 321 358 358 358 369 364 0 0 0 0 277 372 371 377 328 371 777 666
|
||||
556 722 333 578 578 578 578 578 578 578 578 222 222 222 222 222 222 222 222 546
|
||||
546 546 546 546 546 546 546 222 222 222 222 546 546 546 546 543 600 453 666 722
|
||||
667 666 556 500 222 737 556 722 333 666 500 500 500 500 222 541 364 666 500 666
|
||||
500 604 458 656 583 0 0 0 0 0 0 0 0 0 942 489 500 556 222 556
|
||||
666 722 556 277 722 556 666 500 610 500 500 577 425 648 0 0 0 0 0 0
|
||||
222 723 722 723 0 0 0 0 0 0 0 0 0 0 0 777 556 943 722 702
|
||||
0 732 596 1037 840 277 437 190 190 500 500 277 277 277 333 0 0 0 0 0
|
||||
0 0 0 610 556 556 383 539 534 556 539 561 519 556 559 556 387 556 556 556
|
||||
556 561 522 556 560 721 728 746 1161 746 375 656 777 555 222 496 254 556 289 558
|
||||
556 556 375 254 222 555 566 595 612 554 503 647 617 239 431 566 466 722 615 648
|
||||
553 648 606 553 507 607 550 793 554 552 506 820 833 466 648 554 612 595 555 555
|
||||
555 555 555 555 595 554 554 554 554 239 239 239 239 615 648 648 648 648 648 607
|
||||
607 607 607 552 555 555 555 595 595 595 595 612 612 554 554 554 554 554 647 647
|
||||
647 647 617 618 239 239 239 239 239 657 431 566 466 466 466 466 615 615 615 619
|
||||
648 648 648 606 606 606 553 553 553 553 553 507 507 507 506 607 607 607 607 607
|
||||
607 793 793 793 793 552 552 552 506 506 506 555 820 648 555 566 459 555 554 506
|
||||
617 648 239 566 543 722 615 522 648 612 553 518 507 552 659 554 657 648 555 554
|
||||
617 239 648 552 648 239 552 554 710 459 597 553 239 239 431 869 838 731 510 548
|
||||
612 555 565 566 459 551 554 791 515 611 611 510 551 722 617 648 612 553 595 507
|
||||
548 631 554 607 561 769 764 685 737 541 596 835 606 392 333 333 333 333 333 333
|
||||
333 333 333 333 333 333 333 721 721 721 721 721 721 721 721 721 721 721 728 728
|
||||
728 728 728 728 728 728 728 728 728 746 746 746 746 746 746 746 746 746 746 746
|
||||
746 746 375 375 375 375 375 375 375 375 375 510 375 375 375 254 254 301 330 254
|
||||
375 375 375 375 375 375 375 375 656 555 555 555 555 555 555 555 555 555 222 496
|
||||
254 254 301 330 254 289 289 375 289 558 558 558 558 578 333 333 333 333 616 615
|
||||
615 755 604 735 268 268 268 268 268 268 268 268 268 268 268 268 268 268 268 1573
|
||||
1755 0 1852 0 0 0 0 562 525 529 529 562 525 529 529 821 821 530 530 488
|
||||
488 562 525 529 529 207 229 207 229 638 588 244 244 638 588 244 244 638 588 244
|
||||
244 432 432 432 432 812 812 812 812 562 525 529 529 821 821 530 530 821 821 530
|
||||
530 601 601 394 394 587 625 573 611 919 731 881 634 1464 0 0 0 0 0 638
|
||||
588 244 244 812 932 394 514 812 932 394 514 638 588 244 244 638 588 244 244 638
|
||||
588 244 244 0 577 475 610 458 718 583 666 556 1299 556 666 959 760 788 717 957
|
||||
856 666 500 1068 884 1131 850 722 541 704 554 277 277 556 766 397 590 556 668 575
|
||||
833 666 732 695 333 556 489 159 321 666 610 277 779 1416 1036 1380 1852 207 207 207
|
||||
229 207 207 207 207 289 207 207 207 207 207 207 207 207 207 207 207 244 244 244
|
||||
244 244 272 244 199 343 343 556 364 364 519 519 638 638 638 638 638 638 638 638
|
||||
562 562 486 562 562 486 713 713 244 244 562 525 529 529 581 581 581 581 788 788
|
||||
267 262 581 581 267 262 506 506 207 207 337 337 394 394 638 588 244 244 638 588
|
||||
244 244 464 464 432 432 427 427 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 543 600 0 398 508
|
||||
602 642 0 0 318 318 533 529 533 529 533 529 533 533 529 581 318 394 273 184
|
||||
0 792 739 724 715 717 724 708 597 723 806 715 658 527 924 766 695 615 706 717
|
||||
700 754 716 708 699 724 699 792 738 764 724 698 659 678 677 515 761 686 782 762
|
||||
273 222 169 200 265 231 513 832 550 579 582 553 550 491 550 667 579 550 219 834
|
||||
542 553 550 522 553 558 550 219 553 456 550 346 832 517 563 550 550 831 550 555
|
||||
394 831 550 554 743 712 277 324 1000 1000 726 1104 1104 1101 1104 1385 556 1000 0 0
|
||||
713 713 244 244 170 337 337 1098 1098 846 846 812 932 394 514 282 196 488 488 0
|
||||
500 722 552 1329 1069 666 564 656 583 829 786 534 752 752 536 743 794 543 450 525
|
||||
394 601 601 394 394 0 277 208 277 208 722 556 829 627 552 552 516 516 586 586
|
||||
503 553 1155 912 1187 918 1020 890 962 734 962 734 962 734 722 500 666 500 666 500
|
||||
666 500 650 310 556 222 802 610 877 651 1364 951 666 556 828 700 933 809 777 556
|
||||
979 747 581 410 581 581 666 500 943 722 548 493 666 556 666 556 509 408 445 445
|
||||
501 501 561 561 326 676 344 961 680 333 750 672 475 777 556 404 333 589 589 577
|
||||
556 222 799 598 404 333 643 500 722 444 767 601 722 500 500 556 800 684 653 277
|
||||
668 524 714 548 668 524 777 556 666 500 722 556 722 333 666 500 806 604 732 684
|
||||
666 610 523 735 666 575 1002 780 769 448 639 833 610 666 833 277 1185 578 900 478
|
||||
556 666 277 556 368 346 242 851 569 556 547 547 610 943 943 943 951 951 549 606
|
||||
333 502 457 627 474 699 222 556 556 833 833 612 524 613 593 604 500 604 500 500
|
||||
333 383 273 247 415 720 765 943 918 556 648 666 610 713 713 244 244 713 713 244
|
||||
244 713 713 385 385 488 488 638 588 244 244 788 788 267 262 581 581 267 262 525
|
||||
525 244 244 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
|
||||
483 1056]]
|
||||
>>
|
||||
endobj
|
||||
|
||||
6 0 obj
|
||||
<</Registry (Adobe)
|
||||
/Ordering (Identity)
|
||||
/Supplement 0
|
||||
>>
|
||||
endobj
|
||||
|
||||
7 0 obj
|
||||
<</Type /FontDescriptor
|
||||
/FontName /PBEWKK+Arial
|
||||
/FontBBox [-664 -324 2000 1039]
|
||||
/Ascent 905
|
||||
/Descent -211
|
||||
/CapHeight 0
|
||||
/ItalicAngle 0
|
||||
/StemV 0
|
||||
/Flags 262176
|
||||
/FontFile2 8 0 R
|
||||
/CIDSet 10 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
8 0 obj
|
||||
<</Filter /FlateDecode
|
||||
/Length 31603
|
||||
/Length1 74505
|
||||
>>
|
||||
stream
|
||||
x<EFBFBD><EFBFBD><EFBFBD> `<14><>?<3F><><EFBFBD>\<5C>;3<><33><EFBFBD><EFBFBD><EFBFBD>n6لl <20><>#<10>"<22>"<22><>d%<25>} <01><><EFBFBD><EFBFBD>4<><34><EFBFBD>g<EFBFBD><67>}R+<2B>T[<5B>B<EFBFBD><42><EFBFBD><EFBFBD>j-<2D>Ѣ|-<2D><16><><EFBFBD>y<EFBFBD><79><EFBFBD>l<EFBFBD><6C><EFBFBD><EFBFBD><EFBFBD>nv晙wf<77>y<EFBFBD><79>s<EFBFBD><73>;A!dF<64><46>C<EFBFBD>ܵk"<22>^<5E><>.<2E><>B<>UV.\><3E>iO!ɂ<><C982>Y<EFBFBD><59><EFBFBD>_l<5F><6C>Y<EFBFBD><1C>Z<><5A>E<EFBFBD><45><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><11>n<06><19>v8<76><03>a<EFBFBD>ϰ]<5D>h<EFBFBD><68>u<EFBFBD>E<EFBFBD><45><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>V̝s<CC9D>$<24><><EFBFBD>L<EFBFBD>n]>g<>J<EFBFBD><4A><EFBFBD><EFBFBD>{<7B>z(<28>r<EFBFBD><72><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><0B>~!<21>V؏<56><D88F><08>"?G><3E>r<EFBFBD><72><EFBFBD>3<EFBFBD><33>.<2E>}F<><46>5<EFBFBD><02><><EFBFBD><EFBFBD>zmË<6D>6<EFBFBD>
|
||||
z<15><><EFBFBD><EFBFBD><EFBFBD>}<7D><13>
|
||||
y<EFBFBD>Hx<EFBFBD><EFBFBD><EFBFBD>Gh<12>L<EFBFBD>s;<3B>_<01><><08>s<EFBFBD><73>=<0E><>8:e/C7<43><37>ȃ}<7D><>э<EFBFBD>V<EFBFBD>m8<6D>V<EFBFBD><56>24MB+Нx\<5C>j4}<7D>ߌ<06>q<EFBFBD>*<2A><12><>f<EFBFBD><66><EFBFBD>ݛ{
|
||||
=<3D><>q<EFBFBD><71>u#<05>\<5C><1E>})<29>1<EFBFBD>g<EFBFBD>θ=<3D>><3E><><EFBFBD>w<EFBFBD>$ܥJ<><04>Bsi<1E><16><>B
|
||||
<EFBFBD><EFBFBD>P<1E>GG<47>!<21><><EFBFBD><EFBFBD>G<EFBFBD>b^ύ<><CF8D><<3C><><EFBFBD>C<>J<>E<EFBFBD>a<EFBFBD><0F><17><>0+7>wy<><1E><><EFBFBD><0F><>h|<7C><><EFBFBD><EFBFBD>}<7D><08>rO<72>N!?<3F>Ac<41>y:<3A>o<EFBFBD>!.۽!<21>-&@+<2B>A<EFBFBD>pd<05>z<1D>1<EFBFBD>s<EFBFBD>BP<42>:!)\<5C>{<07><> | ||||