feat: Download Filled Claim Form; BCBS AI claim returns to schedule date
- "Download Claim PDF for Column" -> "Download Filled Claim Form": ZIP of the ADA forms the app filled (e.g. BCBS) for appointments on the selected schedule date, all columns; Selenium-submitted claims are not included (GET /api/claims/filled-forms replaces /batch-pdf) - After a BCBS claim from "Claim for column with AI", go back to the schedule even when the queue is finished - Schedule remembers the selected date for the tab, so returning from the claims page keeps the working date instead of jumping to today Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1046,74 +1046,68 @@ 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.
|
||||
// GET /api/claims/filled-forms?date=YYYY-MM-DD
|
||||
// ZIP of the claim forms the app fills itself (ADA paper forms, e.g. BCBS) for
|
||||
// appointments on that schedule date. Claims submitted through Selenium are not included.
|
||||
router.get(
|
||||
"/batch-pdf",
|
||||
"/filled-forms",
|
||||
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)));
|
||||
const appointments = await storage.getAppointmentsByDateForUser(date, req.user.id);
|
||||
const appointmentIds = appointments.map((a) => a.id).filter((id): id is number => id != null);
|
||||
|
||||
const { prisma: db } = await import("@repo/db/client");
|
||||
const claims = appointmentIds.length
|
||||
? await db.claim.findMany({
|
||||
where: {
|
||||
appointmentId: { in: appointmentIds },
|
||||
userId: req.user.id,
|
||||
status: { notIn: ["VOID", "CANCELLED"] },
|
||||
},
|
||||
select: { id: true, patientId: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
type PdfEntry = { filename: string; data: Buffer };
|
||||
const pdfEntries: PdfEntry[] = [];
|
||||
|
||||
for (const apt of appointments) {
|
||||
const patientId = apt.patientId;
|
||||
if (!patientId) continue;
|
||||
const claimIdsByPatient = new Map<number, Set<number>>();
|
||||
for (const c of claims) {
|
||||
if (!claimIdsByPatient.has(c.patientId)) claimIdsByPatient.set(c.patientId, new Set());
|
||||
claimIdsByPatient.get(c.patientId)!.add(c.id);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
for (const [patientId, claimIds] of claimIdsByPatient) {
|
||||
// Filled forms are saved as ADA_Claim_<Last>_<First>_<claimId>.pdf in the patient's "Claims" folder;
|
||||
// keep the newest file per claim
|
||||
const newestByClaim = new Map<number, { id: number; name: string }>();
|
||||
for (const cf of await storage.listPatientCategoryCloudFiles(patientId, "Claims")) {
|
||||
const m = /^ADA_Claim_.*_(\d+)\.pdf$/.exec(cf.name ?? "");
|
||||
if (!m || cf.id == null) continue;
|
||||
const claimId = Number(m[1]);
|
||||
if (!claimIds.has(claimId)) continue;
|
||||
const prev = newestByClaim.get(claimId);
|
||||
if (!prev || cf.id > prev.id) newestByClaim.set(claimId, { id: cf.id, name: cf.name! });
|
||||
}
|
||||
|
||||
// New claim PDFs are saved to the patient's Cloud Storage "Claims" subfolder
|
||||
const cloudFiles = await storage.listPatientCategoryCloudFiles(patientId, "Claims");
|
||||
for (const cf of cloudFiles) {
|
||||
if (cf.id == null) continue;
|
||||
const full = await storage.getFile(cf.id);
|
||||
for (const { id, name } of newestByClaim.values()) {
|
||||
const full = await storage.getFile(id);
|
||||
if (!full?.diskPath) continue;
|
||||
const abs = path.join(process.cwd(), full.diskPath);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const buf = fs.readFileSync(abs);
|
||||
if (!buf.length) continue;
|
||||
const safeName = (cf.name || "claim.pdf").replace(/[/\\]/g, "_").trim() || "claim.pdf";
|
||||
pdfEntries.push({ filename: safeName, data: buf });
|
||||
if (buf.length) pdfEntries.push({ filename: name, data: buf });
|
||||
}
|
||||
}
|
||||
|
||||
if (pdfEntries.length === 0) {
|
||||
return res.status(404).json({ error: "No claim PDFs found for the selected columns and date" });
|
||||
return res.status(404).json({ error: `No filled claim forms for ${date}` });
|
||||
}
|
||||
|
||||
const zipFilename = `claims_${date}.zip`;
|
||||
const zipFilename = `filled_claim_forms_${date}.zip`;
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${zipFilename}"`);
|
||||
|
||||
@@ -1123,7 +1117,7 @@ router.get(
|
||||
let archiveError: Error | null = null;
|
||||
archive.on("error", (err) => {
|
||||
archiveError = err;
|
||||
console.error("[batch-pdf] archiver error:", err);
|
||||
console.error("[filled-forms] archiver error:", err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `ZIP creation failed: ${err.message}` });
|
||||
}
|
||||
@@ -1147,9 +1141,9 @@ router.get(
|
||||
await archive.finalize();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[batch-pdf] error:", err);
|
||||
console.error("[filled-forms] error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err?.message ?? "Batch PDF download failed" });
|
||||
return res.status(500).json({ error: err?.message ?? "Filled claim form download failed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user