fix: add HTTP polling fallback for selenium job results through Cloudflare
Socket.IO reconnects through Cloudflare proxy get a new socket.id, causing the backend to emit job:update to a stale socket that no longer exists. The PDF viewer modal never opened even though PDFs were saved successfully. Adds a GET /api/insurance-status/job-status/:jobId endpoint backed by InProcessQueue.getJob(), and a waitForSeleniumJob() helper on the frontend that races socket events against HTTP polling every 3s. Whichever resolves first wins, so local (socket) and external (Cloudflare) both work reliably. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,13 @@ export function enqueueSeleniumJob(data: SeleniumJobData): string {
|
|||||||
return jobId;
|
return jobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Job status query (HTTP polling fallback) ─────────────────────────────────
|
||||||
|
export function getSeleniumJobStatus(jobId: string) {
|
||||||
|
const job = seleniumQ.getJob(jobId);
|
||||||
|
if (!job) return null;
|
||||||
|
return { status: job.status, result: job.result ?? null, error: job.error ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
// ── OCR enqueue ──────────────────────────────────────────────────────────────
|
// ── OCR enqueue ──────────────────────────────────────────────────────────────
|
||||||
export function enqueueOcrJob(data: OcrJobData): string {
|
export function enqueueOcrJob(data: OcrJobData): string {
|
||||||
const { socketId } = data;
|
const { socketId } = data;
|
||||||
|
|||||||
@@ -15,10 +15,18 @@ import {
|
|||||||
} from "../../../../packages/db/types/patient-types";
|
} from "../../../../packages/db/types/patient-types";
|
||||||
import { formatDobForAgent } from "../utils/dateUtils";
|
import { formatDobForAgent } from "../utils/dateUtils";
|
||||||
import { seleniumQueue } from "../queue/queues";
|
import { seleniumQueue } from "../queue/queues";
|
||||||
import { enqueueSeleniumJob } from "../queue/jobRunner";
|
import { enqueueSeleniumJob, getSeleniumJobStatus } from "../queue/jobRunner";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/** GET /job-status/:jobId — HTTP polling fallback when socket.io drops through Cloudflare */
|
||||||
|
router.get("/job-status/:jobId", (req: Request, res: Response): any => {
|
||||||
|
const jobId = String(req.params.jobId ?? "");
|
||||||
|
const status = getSeleniumJobStatus(jobId);
|
||||||
|
if (!status) return res.status(404).json({ error: "Job not found" });
|
||||||
|
return res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
/** Utility: naive name splitter */
|
/** Utility: naive name splitter */
|
||||||
function splitName(fullName?: string | null) {
|
function splitName(fullName?: string | null) {
|
||||||
if (!fullName) return { firstName: "", lastName: "" };
|
if (!fullName) return { firstName: "", lastName: "" };
|
||||||
|
|||||||
@@ -42,6 +42,56 @@ import { TuftsSCOEligibilityButton } from "@/components/insurance-status/tufts-s
|
|||||||
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
import { UnitedSCOEligibilityButton } from "@/components/insurance-status/united-sco-button-modal";
|
||||||
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
import { CCAEligibilityButton } from "@/components/insurance-status/cca-button-modal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for a Selenium job to complete by racing socket.io events against
|
||||||
|
* HTTP polling every 3 seconds. This ensures the result is received even
|
||||||
|
* when the socket reconnects through Cloudflare (changing socket.id).
|
||||||
|
*/
|
||||||
|
function waitForSeleniumJob(
|
||||||
|
jobId: string,
|
||||||
|
onActive?: (message: string) => void,
|
||||||
|
): Promise<any> {
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (fn: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
socket.off("job:update", socketHandler);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const socketHandler = (payload: any) => {
|
||||||
|
if (String(payload.jobId) !== String(jobId)) return;
|
||||||
|
if (payload.status === "active") {
|
||||||
|
onActive?.(payload.message ?? "Selenium running...");
|
||||||
|
} else if (payload.status === "completed") {
|
||||||
|
settle(() => resolve(payload.result ?? {}));
|
||||||
|
} else if (payload.status === "failed") {
|
||||||
|
settle(() => reject(new Error(payload.error ?? "Selenium job failed")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on("job:update", socketHandler);
|
||||||
|
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
if (settled) return;
|
||||||
|
try {
|
||||||
|
const res = await apiRequest("GET", `/api/insurance-status/job-status/${jobId}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === "completed") {
|
||||||
|
settle(() => resolve(data.result ?? {}));
|
||||||
|
} else if (data.status === "failed") {
|
||||||
|
settle(() => reject(new Error(data.error ?? "Selenium job failed")));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore transient poll errors
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function InsuranceStatusPage() {
|
export default function InsuranceStatusPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -254,27 +304,9 @@ export default function InsuranceStatusPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Promise<any>((resolve, reject) => {
|
return waitForSeleniumJob(jobId, (msg) =>
|
||||||
const handler = (payload: any) => {
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
||||||
if (String(payload.jobId) !== String(jobId)) return;
|
|
||||||
if (payload.status === "active") {
|
|
||||||
dispatch(
|
|
||||||
setTaskStatus({
|
|
||||||
key: "eligibilityCheck",
|
|
||||||
status: "pending",
|
|
||||||
message: payload.message ?? "Selenium running...",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
} else if (payload.status === "completed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
resolve(payload.result ?? {});
|
|
||||||
} else if (payload.status === "failed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
reject(new Error(payload.error ?? "Selenium job failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on("job:update", handler);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddPatient = async () => {
|
const handleAddPatient = async () => {
|
||||||
@@ -468,21 +500,9 @@ export default function InsuranceStatusPage() {
|
|||||||
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
||||||
|
|
||||||
const jobResult = await new Promise<any>((resolve, reject) => {
|
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
|
||||||
const handler = (payload: any) => {
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
||||||
if (String(payload.jobId) !== String(jobId)) return;
|
);
|
||||||
if (payload.status === "active") {
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: payload.message ?? "Selenium running..." }));
|
|
||||||
} else if (payload.status === "completed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
resolve(payload.result ?? {});
|
|
||||||
} else if (payload.status === "failed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
reject(new Error(payload.error ?? "Selenium job failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on("job:update", handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "Eligibility and service history PDFs saved to Documents." }));
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "Eligibility and service history PDFs saved to Documents." }));
|
||||||
toast({
|
toast({
|
||||||
@@ -549,21 +569,9 @@ export default function InsuranceStatusPage() {
|
|||||||
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: "Selenium browser starting..." }));
|
||||||
|
|
||||||
const jobResult = await new Promise<any>((resolve, reject) => {
|
const jobResult = await waitForSeleniumJob(jobId, (msg) =>
|
||||||
const handler = (payload: any) => {
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: msg }))
|
||||||
if (String(payload.jobId) !== String(jobId)) return;
|
);
|
||||||
if (payload.status === "active") {
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "pending", message: payload.message ?? "Selenium running..." }));
|
|
||||||
} else if (payload.status === "completed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
resolve(payload.result ?? {});
|
|
||||||
} else if (payload.status === "failed") {
|
|
||||||
socket.off("job:update", handler);
|
|
||||||
reject(new Error(payload.error ?? "Selenium job failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.on("job:update", handler);
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "CMSP PDFs saved to Documents." }));
|
dispatch(setTaskStatus({ key: "eligibilityCheck", status: "success", message: "CMSP PDFs saved to Documents." }));
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
Reference in New Issue
Block a user