fix: wait for table rows before extracting eligibility data; add batch claim PDF download
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=5000
|
PORT=5000
|
||||||
FRONTEND_URLS=http://localhost:3000,http://192.168.1.236
|
FRONTEND_URLS=http://localhost:3000,http://192.168.1.236,https://communitydentistsoflowell.mydentalofficemanagement.com
|
||||||
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
SELENIUM_AGENT_BASE_URL=http://localhost:5002
|
||||||
JWT_SECRET = 'dentalsecret'
|
JWT_SECRET = 'dentalsecret'
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import archiver from "archiver";
|
||||||
import { seleniumQueue } from "../queue/queues";
|
import { seleniumQueue } from "../queue/queues";
|
||||||
import { Prisma } from "@repo/db/generated/prisma";
|
import { Prisma } from "@repo/db/generated/prisma";
|
||||||
import { Decimal } from "decimal.js";
|
import { Decimal } from "decimal.js";
|
||||||
@@ -688,6 +689,101 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/claims/batch-pdf
|
||||||
|
// Query params: date=YYYY-MM-DD (required), staffIds=1,2 (required)
|
||||||
|
// Returns a ZIP archive of all INSURANCE_CLAIM PdfFile records for patients
|
||||||
|
// scheduled on that date in the given staff columns.
|
||||||
|
router.get(
|
||||||
|
"/batch-pdf",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
const date = String(req.query.date ?? "").trim();
|
||||||
|
const staffIdsRaw = String(req.query.staffIds ?? "").trim();
|
||||||
|
|
||||||
|
if (!date) return res.status(400).json({ error: "Missing date query param" });
|
||||||
|
if (!staffIdsRaw) return res.status(400).json({ error: "Missing staffIds query param" });
|
||||||
|
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const staffIdFilter = new Set(
|
||||||
|
staffIdsRaw.split(",").map(Number).filter((n) => Number.isFinite(n) && n > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allAppointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
|
||||||
|
const appointments = allAppointments.filter((a) => staffIdFilter.has(Number(a.staffId)));
|
||||||
|
|
||||||
|
type PdfEntry = { filename: string; data: Buffer };
|
||||||
|
const pdfEntries: PdfEntry[] = [];
|
||||||
|
|
||||||
|
for (const apt of appointments) {
|
||||||
|
const patientId = apt.patientId;
|
||||||
|
if (!patientId) continue;
|
||||||
|
|
||||||
|
// Claim PDFs from Selenium are stored in PdfGroup (titleKey=INSURANCE_CLAIM) → PdfFile (binary pdfData)
|
||||||
|
const group = await storage.findPdfGroupByPatientTitleKey(patientId, "INSURANCE_CLAIM");
|
||||||
|
if (!group?.id) continue;
|
||||||
|
|
||||||
|
const raw = await storage.getPdfFilesByGroupId(group.id);
|
||||||
|
const files = (Array.isArray(raw) ? raw : (raw as any).data ?? []) as Array<{ filename: string; pdfData: unknown }>;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.pdfData) continue;
|
||||||
|
// Prisma Bytes → always convert to Buffer to satisfy archiver
|
||||||
|
const buf = Buffer.isBuffer(f.pdfData)
|
||||||
|
? f.pdfData
|
||||||
|
: Buffer.from(f.pdfData as any);
|
||||||
|
if (!buf.length) continue;
|
||||||
|
// Sanitize filename: strip path components, keep safe chars
|
||||||
|
const safeName = (f.filename || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf";
|
||||||
|
pdfEntries.push({ filename: safeName, data: buf });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfEntries.length === 0) {
|
||||||
|
return res.status(404).json({ error: "No claim PDFs found for the selected columns and date" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFilename = `claims_${date}.zip`;
|
||||||
|
res.setHeader("Content-Type", "application/zip");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`);
|
||||||
|
|
||||||
|
const archive = archiver("zip", { zlib: { level: 6 } });
|
||||||
|
|
||||||
|
// Capture archiver errors before any data is written
|
||||||
|
let archiveError: Error | null = null;
|
||||||
|
archive.on("error", (err) => {
|
||||||
|
archiveError = err;
|
||||||
|
console.error("[batch-pdf] archiver error:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: `ZIP creation failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
const seenNames = new Map<string, number>();
|
||||||
|
for (const entry of pdfEntries) {
|
||||||
|
if (archiveError) break;
|
||||||
|
const count = seenNames.get(entry.filename) ?? 0;
|
||||||
|
seenNames.set(entry.filename, count + 1);
|
||||||
|
const archiveName =
|
||||||
|
count === 0
|
||||||
|
? entry.filename
|
||||||
|
: `${path.basename(entry.filename, ".pdf")}_${count}.pdf`;
|
||||||
|
archive.append(entry.data, { name: archiveName });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archiveError) {
|
||||||
|
await archive.finalize();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[batch-pdf] error:", err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ error: err?.message ?? "Batch PDF download failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/claims/by-appointment/:appointmentId
|
// GET /api/claims/by-appointment/:appointmentId
|
||||||
// Returns the most recent active (non-cancelled/void) claim with service lines and files
|
// Returns the most recent active (non-cancelled/void) claim with service lines and files
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
FileCheck,
|
FileCheck,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
@@ -154,6 +155,8 @@ export default function AppointmentsPage() {
|
|||||||
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
const [selectedClaimColumns, setSelectedClaimColumns] = useState<Set<number>>(new Set());
|
||||||
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
const [isClaimingColumn, setIsClaimingColumn] = useState(false);
|
||||||
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
|
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
|
||||||
|
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
|
||||||
|
|
||||||
const toggleReminderColumn = (staffId: number) => {
|
const toggleReminderColumn = (staffId: number) => {
|
||||||
setSelectedReminderColumns((prev) => {
|
setSelectedReminderColumns((prev) => {
|
||||||
@@ -173,6 +176,15 @@ export default function AppointmentsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleDownloadPdfColumn = (staffId: number) => {
|
||||||
|
setSelectedDownloadPdfColumns((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(staffId)) next.delete(staffId);
|
||||||
|
else next.add(staffId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
// Select Procedures modal state (opened inline, no navigation)
|
// Select Procedures modal state (opened inline, no navigation)
|
||||||
@@ -1037,6 +1049,38 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadClaimPdfs = async () => {
|
||||||
|
if (!user || selectedDownloadPdfColumns.size === 0) return;
|
||||||
|
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
|
||||||
|
setIsDownloadingClaimPdfs(true);
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/claims/batch-pdf?date=${formattedSelectedDate}&staffIds=${staffIdsParam}`
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
let errMsg = `Server error ${res.status}`;
|
||||||
|
try { const body = await res.json(); errMsg = body?.error ?? errMsg; } catch { /* ignore */ }
|
||||||
|
toast({ title: "Download failed", description: errMsg, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `claims_${formattedSelectedDate}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast({ title: "Download started", description: `Claim PDFs for ${formattedSelectedDate} saved to your Downloads folder.` });
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Download failed", description: err?.message ?? String(err), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsDownloadingClaimPdfs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SeleniumTaskBanner
|
<SeleniumTaskBanner
|
||||||
@@ -1174,6 +1218,43 @@ export default function AppointmentsPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Download Claim PDF for Column section */}
|
||||||
|
<div className="flex items-center gap-2 border rounded-md px-3 py-2 bg-white shadow-sm">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownloadClaimPdfs()}
|
||||||
|
disabled={isLoading || isDownloadingClaimPdfs || selectedDownloadPdfColumns.size === 0}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isDownloadingClaimPdfs ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircleIcon className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
Downloading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Download Claim PDF for Column
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{staffMembers.map((staff, index) => (
|
||||||
|
<label
|
||||||
|
key={staff.id}
|
||||||
|
className="flex items-center gap-1 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded border-gray-400 accent-teal-600"
|
||||||
|
checked={selectedDownloadPdfColumns.has(Number(staff.id))}
|
||||||
|
onChange={() => toggleDownloadPdfColumn(Number(staff.id))}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{String.fromCharCode(65 + index)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -180,14 +180,15 @@ class AutomationMassHealthEligibilityCheck:
|
|||||||
return ''.join(c for c in str(s) if c.isalnum()).lower()
|
return ''.join(c for c in str(s) if c.isalnum()).lower()
|
||||||
|
|
||||||
def _extract_data_from_page(self):
|
def _extract_data_from_page(self):
|
||||||
wait = WebDriverWait(self.driver, 5)
|
wait = WebDriverWait(self.driver, 15)
|
||||||
extracted = {}
|
extracted = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Wait until Eligible or Ineligible header appears
|
# Wait until at least one table row appears under Eligible or Ineligible —
|
||||||
|
# the h4 header renders before the rows are populated, so we must wait for rows.
|
||||||
wait.until(
|
wait.until(
|
||||||
EC.presence_of_element_located(
|
EC.presence_of_element_located(
|
||||||
(By.XPATH, "//h4[text()='Eligible' or text()='Ineligible']")
|
(By.XPATH, "//h4[text()='Eligible' or text()='Ineligible']/following::table[1]/tbody/tr")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user