feat: rewire routes to BullMQ and speed up documents page
This commit is contained in:
@@ -2,6 +2,8 @@ import app from "./app";
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import { initSocket } from "./socket";
|
import { initSocket } from "./socket";
|
||||||
|
import { startSeleniumWorker } from "./queue/workers/seleniumWorker";
|
||||||
|
import { startOcrWorker } from "./queue/workers/ocrWorker";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ const server = http.createServer(app);
|
|||||||
// Initialize socket.io on this server
|
// Initialize socket.io on this server
|
||||||
initSocket(server);
|
initSocket(server);
|
||||||
|
|
||||||
|
// Start BullMQ workers (requires Redis at localhost:6379)
|
||||||
|
startSeleniumWorker();
|
||||||
|
startOcrWorker();
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
`✅ Server running in ${NODE_ENV} mode at http://${HOST}:${PORT}`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import multer from "multer";
|
|||||||
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
import { forwardToSeleniumClaimAgent } from "../services/seleniumClaimClient";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
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";
|
||||||
import {
|
import {
|
||||||
@@ -138,31 +139,27 @@ router.post(
|
|||||||
massdhpPassword: credentials.password,
|
massdhpPassword: credentials.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await forwardToSeleniumClaimAgent(enrichedData, [
|
// Encode file buffers as base64 so they can be stored in Redis
|
||||||
...pdfs,
|
const filesForQueue = [...pdfs, ...images].map((f) => ({
|
||||||
...images,
|
originalname: f.originalname,
|
||||||
]);
|
bufferBase64: f.buffer.toString("base64"),
|
||||||
|
mimetype: f.mimetype,
|
||||||
|
}));
|
||||||
|
|
||||||
// Store claimNumber if returned from Selenium
|
const job = await seleniumQueue.add("claim-submit", {
|
||||||
if (result?.claimNumber && claimData.claimId) {
|
jobType: "claim-submit",
|
||||||
try {
|
userId: req.user.id,
|
||||||
await storage.updateClaim(claimData.claimId, {
|
socketId: req.body.socketId,
|
||||||
claimNumber: result.claimNumber,
|
enrichedPayload: enrichedData,
|
||||||
});
|
files: filesForQueue,
|
||||||
console.log(`Updated claim ${claimData.claimId} with claimNumber: ${result.claimNumber}`);
|
|
||||||
} catch (updateErr) {
|
|
||||||
console.error("Failed to update claim with claimNumber:", updateErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
...result,
|
|
||||||
claimId: claimData.claimId,
|
claimId: claimData.claimId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return res.json({ jobId: job.id, status: "queued" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: err.message || "Failed to forward to selenium agent",
|
error: err.message || "Failed to enqueue selenium claim job",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,19 +316,26 @@ router.post(
|
|||||||
massdhpPassword: credentials.password,
|
massdhpPassword: credentials.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await forwardToSeleniumClaimPreAuthAgent(enrichedData, [
|
const filesForQueue = [...pdfs, ...images].map((f) => ({
|
||||||
...pdfs,
|
originalname: f.originalname,
|
||||||
...images,
|
bufferBase64: f.buffer.toString("base64"),
|
||||||
]);
|
mimetype: f.mimetype,
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({
|
const job = await seleniumQueue.add("claim-pre-auth", {
|
||||||
...result,
|
jobType: "claim-pre-auth",
|
||||||
|
userId: req.user.id,
|
||||||
|
socketId: req.body.socketId,
|
||||||
|
enrichedPayload: enrichedData,
|
||||||
|
files: filesForQueue,
|
||||||
claimId: claimData.claimId,
|
claimId: claimData.claimId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return res.json({ jobId: job.id, status: "queued" });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: err.message || "Failed to forward to selenium agent",
|
error: err.message || "Failed to enqueue selenium pre-auth job",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
insertPatientSchema,
|
insertPatientSchema,
|
||||||
} 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";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -119,20 +120,12 @@ router.post(
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
.json({ error: "Missing Insurance Eligibility data for selenium" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user || !req.user.id) {
|
if (!req.user || !req.user.id) {
|
||||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let seleniumResult: any = undefined;
|
const insuranceEligibilityData =
|
||||||
let createdPdfFileId: number | null = null;
|
typeof req.body.data === "string"
|
||||||
let outputResult: any = {};
|
|
||||||
const extracted: any = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// const insuranceEligibilityData = JSON.parse(req.body.data);
|
|
||||||
// Handle both string and object data
|
|
||||||
const insuranceEligibilityData = typeof req.body.data === 'string'
|
|
||||||
? JSON.parse(req.body.data)
|
? JSON.parse(req.body.data)
|
||||||
: req.body.data;
|
: req.body.data;
|
||||||
|
|
||||||
@@ -147,213 +140,29 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insuranceId = String(insuranceEligibilityData.memberId ?? "").trim();
|
||||||
|
if (!insuranceId) {
|
||||||
|
return res.status(400).json({ error: "Missing memberId" });
|
||||||
|
}
|
||||||
|
|
||||||
const enrichedData = {
|
const enrichedData = {
|
||||||
...insuranceEligibilityData,
|
...insuranceEligibilityData,
|
||||||
massdhpUsername: credentials.username,
|
massdhpUsername: credentials.username,
|
||||||
massdhpPassword: credentials.password,
|
massdhpPassword: credentials.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1) Run selenium agent
|
const job = await seleniumQueue.add("eligibility-check", {
|
||||||
try {
|
jobType: "eligibility-check",
|
||||||
seleniumResult =
|
|
||||||
await forwardToSeleniumInsuranceEligibilityAgent(enrichedData);
|
|
||||||
} catch (seleniumErr: any) {
|
|
||||||
return res.status(502).json({
|
|
||||||
error: "Selenium service failed",
|
|
||||||
detail: seleniumErr?.message ?? String(seleniumErr),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Extract data from selenium result (page extraction) and PDF
|
|
||||||
let extracted: any = {};
|
|
||||||
|
|
||||||
// First, try to get data from selenium's page extraction
|
|
||||||
if (seleniumResult.firstName || seleniumResult.lastName) {
|
|
||||||
extracted.firstName = seleniumResult.firstName || null;
|
|
||||||
extracted.lastName = seleniumResult.lastName || null;
|
|
||||||
console.log('[eligibility-check] Using name from selenium extraction:', {
|
|
||||||
firstName: extracted.firstName,
|
|
||||||
lastName: extracted.lastName
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Also check for combined name field (fallback)
|
|
||||||
else if (seleniumResult.name) {
|
|
||||||
const parts = splitName(seleniumResult.name);
|
|
||||||
extracted.firstName = parts.firstName;
|
|
||||||
extracted.lastName = parts.lastName;
|
|
||||||
console.log('[eligibility-check] Using combined name from selenium extraction:', parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no name from selenium, try PDF extraction
|
|
||||||
if (!extracted.firstName && !extracted.lastName &&
|
|
||||||
seleniumResult?.pdf_path &&
|
|
||||||
seleniumResult.pdf_path.endsWith(".pdf")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const pdfPath = seleniumResult.pdf_path;
|
|
||||||
console.log('[eligibility-check] Extracting data from PDF:', pdfPath);
|
|
||||||
const pdfBuffer = await fs.readFile(pdfPath);
|
|
||||||
|
|
||||||
const extraction = await forwardToPatientDataExtractorService({
|
|
||||||
buffer: pdfBuffer,
|
|
||||||
originalname: path.basename(pdfPath),
|
|
||||||
mimetype: "application/pdf",
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
console.log('[eligibility-check] PDF Extraction result:', extraction);
|
|
||||||
|
|
||||||
if (extraction.name) {
|
|
||||||
const parts = splitName(extraction.name);
|
|
||||||
extracted.firstName = parts.firstName;
|
|
||||||
extracted.lastName = parts.lastName;
|
|
||||||
console.log('[eligibility-check] Split name from PDF:', parts);
|
|
||||||
} else {
|
|
||||||
console.warn('[eligibility-check] No name extracted from PDF');
|
|
||||||
}
|
|
||||||
} catch (extractErr: any) {
|
|
||||||
console.error('[eligibility-check] Patient data extraction failed:', extractErr);
|
|
||||||
// Continue without extracted names - we'll use form names or create patient with empty names
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step-3) Create or update patient name using extracted info (prefer extractor -> request)
|
|
||||||
const insuranceId = String(
|
|
||||||
insuranceEligibilityData.memberId ?? ""
|
|
||||||
).trim();
|
|
||||||
if (!insuranceId) {
|
|
||||||
return res.status(400).json({ error: "Missing memberId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always prioritize extracted data from MassHealth over form input
|
|
||||||
// Form input is only used as fallback when extraction fails
|
|
||||||
const preferFirst = extracted.firstName || null;
|
|
||||||
const preferLast = extracted.lastName || null;
|
|
||||||
|
|
||||||
console.log('[eligibility-check] Name priority:', {
|
|
||||||
extracted: { firstName: extracted.firstName, lastName: extracted.lastName },
|
|
||||||
fromForm: { firstName: insuranceEligibilityData.firstName, lastName: insuranceEligibilityData.lastName },
|
|
||||||
using: { firstName: preferFirst, lastName: preferLast }
|
|
||||||
});
|
|
||||||
|
|
||||||
let patient;
|
|
||||||
try {
|
|
||||||
patient = await createOrUpdatePatientByInsuranceId({
|
|
||||||
insuranceId,
|
|
||||||
firstName: preferFirst,
|
|
||||||
lastName: preferLast,
|
|
||||||
dob: insuranceEligibilityData.dateOfBirth,
|
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
socketId: req.body.socketId,
|
||||||
console.log('[eligibility-check] Patient after create/update:', patient);
|
enrichedPayload: enrichedData,
|
||||||
} catch (patientOpErr: any) {
|
insuranceId,
|
||||||
return res.status(500).json({
|
formFirstName: insuranceEligibilityData.firstName,
|
||||||
error: "Failed to create/update patient",
|
formLastName: insuranceEligibilityData.lastName,
|
||||||
detail: patientOpErr?.message ?? String(patientOpErr),
|
formDob: insuranceEligibilityData.dateOfBirth,
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Step 4: Update patient status based on selenium result
|
|
||||||
if (patient && patient.id !== undefined) {
|
|
||||||
// Use eligibility from selenium extraction if available, otherwise default to UNKNOWN
|
|
||||||
let newStatus = "UNKNOWN";
|
|
||||||
|
|
||||||
if (seleniumResult.eligibility === "Y") {
|
|
||||||
newStatus = "ACTIVE";
|
|
||||||
} else if (seleniumResult.eligibility === "N") {
|
|
||||||
newStatus = "INACTIVE";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare updates object
|
|
||||||
const updates: any = { status: newStatus };
|
|
||||||
|
|
||||||
// Update insurance provider if extracted
|
|
||||||
if (seleniumResult.insurance) {
|
|
||||||
updates.insuranceProvider = seleniumResult.insurance;
|
|
||||||
console.log('[eligibility-check] Updating insurance provider:', seleniumResult.insurance);
|
|
||||||
}
|
|
||||||
|
|
||||||
await storage.updatePatient(patient.id, updates);
|
|
||||||
outputResult.patientUpdateStatus = `Patient status updated to ${newStatus}${seleniumResult.insurance ? ', insurance updated' : ''}`;
|
|
||||||
console.log('[eligibility-check] Status updated:', {
|
|
||||||
patientId: patient.id,
|
|
||||||
newStatus,
|
|
||||||
eligibility: seleniumResult.eligibility,
|
|
||||||
insurance: seleniumResult.insurance
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✅ Step 5: Handle PDF Upload
|
return res.json({ jobId: job.id, status: "queued" });
|
||||||
if (
|
|
||||||
seleniumResult.pdf_path &&
|
|
||||||
seleniumResult.pdf_path.endsWith(".pdf")
|
|
||||||
) {
|
|
||||||
const pdfBuffer = await fs.readFile(seleniumResult.pdf_path);
|
|
||||||
|
|
||||||
const groupTitle = "Eligibility Status";
|
|
||||||
const groupTitleKey = "ELIGIBILITY_STATUS";
|
|
||||||
|
|
||||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
|
||||||
patient.id,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 5b: Create group if it doesn’t exist
|
|
||||||
if (!group) {
|
|
||||||
group = await storage.createPdfGroup(
|
|
||||||
patient.id,
|
|
||||||
groupTitle,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!group?.id) {
|
|
||||||
throw new Error("PDF group creation failed: missing group ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await storage.createPdfFile(
|
|
||||||
group.id,
|
|
||||||
path.basename(seleniumResult.pdf_path),
|
|
||||||
pdfBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
// created could be { id, filename } or just id, adapt to your storage API.
|
|
||||||
if (created && typeof created === "object" && "id" in created) {
|
|
||||||
createdPdfFileId = Number(created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
outputResult.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
|
||||||
} else {
|
|
||||||
outputResult.pdfUploadStatus =
|
|
||||||
"No valid PDF path provided by Selenium, Couldn't upload pdf to server.";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outputResult.patientUpdateStatus =
|
|
||||||
"Patient not found or missing ID; no update performed";
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
patientUpdateStatus: outputResult.patientUpdateStatus,
|
|
||||||
pdfUploadStatus: outputResult.pdfUploadStatus,
|
|
||||||
pdfFileId: createdPdfFileId,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: err.message || "Failed to forward to selenium agent",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (seleniumResult && seleniumResult.pdf_path) {
|
|
||||||
await emptyFolderContainingFile(seleniumResult.pdf_path);
|
|
||||||
} else {
|
|
||||||
console.log(`[eligibility-check] no pdf_path available to cleanup`);
|
|
||||||
}
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error(
|
|
||||||
`[eligibility-check cleanup failed for ${seleniumResult?.pdf_path}`,
|
|
||||||
cleanupErr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,43 +174,14 @@ router.post(
|
|||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Missing Insurance Status data for selenium" });
|
.json({ error: "Missing Insurance Status data for selenium" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.user || !req.user.id) {
|
if (!req.user || !req.user.id) {
|
||||||
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
return res.status(401).json({ error: "Unauthorized: user info missing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: any = undefined;
|
const insuranceClaimStatusData =
|
||||||
|
typeof req.body.data === "string"
|
||||||
async function imageToPdfBuffer(imagePath: string): Promise<Buffer> {
|
? JSON.parse(req.body.data)
|
||||||
return new Promise<Buffer>((resolve, reject) => {
|
: req.body.data;
|
||||||
try {
|
|
||||||
const doc = new PDFDocument({ autoFirstPage: false });
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
|
|
||||||
// collect data chunks
|
|
||||||
doc.on("data", (chunk: any) => chunks.push(chunk));
|
|
||||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
||||||
doc.on("error", (err: any) => reject(err));
|
|
||||||
|
|
||||||
const A4_WIDTH = 595.28; // points
|
|
||||||
const A4_HEIGHT = 841.89; // points
|
|
||||||
|
|
||||||
doc.addPage({ size: [A4_WIDTH, A4_HEIGHT] });
|
|
||||||
|
|
||||||
doc.image(imagePath, 0, 0, {
|
|
||||||
fit: [A4_WIDTH, A4_HEIGHT],
|
|
||||||
align: "center",
|
|
||||||
valign: "center",
|
|
||||||
});
|
|
||||||
|
|
||||||
doc.end();
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const insuranceClaimStatusData = JSON.parse(req.body.data);
|
|
||||||
|
|
||||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
@@ -420,115 +200,15 @@ router.post(
|
|||||||
massdhpPassword: credentials.password,
|
massdhpPassword: credentials.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
result = await forwardToSeleniumInsuranceClaimStatusAgent(enrichedData);
|
const job = await seleniumQueue.add("claim-status-check", {
|
||||||
|
jobType: "claim-status-check",
|
||||||
let createdPdfFileId: number | null = null;
|
userId: req.user.id,
|
||||||
|
socketId: req.body.socketId,
|
||||||
// ✅ Step 1: Check result
|
enrichedPayload: enrichedData,
|
||||||
const patient = await storage.getPatientByInsuranceId(
|
insuranceId: String(insuranceClaimStatusData.memberId ?? "").trim(),
|
||||||
insuranceClaimStatusData.memberId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (patient && patient.id !== undefined) {
|
|
||||||
let pdfBuffer: Buffer | null = null;
|
|
||||||
let generatedPdfPath: string | null = null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.ss_path &&
|
|
||||||
(result.ss_path.endsWith(".png") ||
|
|
||||||
result.ss_path.endsWith(".jpg") ||
|
|
||||||
result.ss_path.endsWith(".jpeg"))
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// Ensure file exists
|
|
||||||
if (!fsSync.existsSync(result.ss_path)) {
|
|
||||||
throw new Error(`Screenshot file not found: ${result.ss_path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert image to PDF buffer
|
|
||||||
pdfBuffer = await imageToPdfBuffer(result.ss_path);
|
|
||||||
|
|
||||||
// Optionally write generated PDF to temp path (so name is available for createPdfFile)
|
|
||||||
const pdfFileName = `claimStatus_${insuranceClaimStatusData.memberId}_${Date.now()}.pdf`;
|
|
||||||
generatedPdfPath = path.join(
|
|
||||||
path.dirname(result.ss_path),
|
|
||||||
pdfFileName
|
|
||||||
);
|
|
||||||
await fs.writeFile(generatedPdfPath, pdfBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to convert screenshot to PDF:", err);
|
|
||||||
result.pdfUploadStatus = `Failed to convert screenshot to PDF: ${String(err)}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.pdfUploadStatus =
|
|
||||||
"No valid PDF or screenshot path provided by Selenium; nothing to upload.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pdfBuffer && generatedPdfPath) {
|
|
||||||
const groupTitle = "Claim Status";
|
|
||||||
const groupTitleKey = "CLAIM_STATUS";
|
|
||||||
|
|
||||||
let group = await storage.findPdfGroupByPatientTitleKey(
|
|
||||||
patient.id,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create group if missing
|
|
||||||
if (!group) {
|
|
||||||
group = await storage.createPdfGroup(
|
|
||||||
patient.id,
|
|
||||||
groupTitle,
|
|
||||||
groupTitleKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!group?.id) {
|
|
||||||
throw new Error("PDF group creation failed: missing group ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the basename for storage
|
|
||||||
const basename = path.basename(generatedPdfPath);
|
|
||||||
const created = await storage.createPdfFile(
|
|
||||||
group.id,
|
|
||||||
basename,
|
|
||||||
pdfBuffer
|
|
||||||
);
|
|
||||||
|
|
||||||
if (created && typeof created === "object" && "id" in created) {
|
|
||||||
createdPdfFileId = Number(created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.pdfUploadStatus = `PDF saved to group: ${group.title}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.patientUpdateStatus =
|
|
||||||
"Patient not found or missing ID; no update performed";
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
pdfUploadStatus: result.pdfUploadStatus,
|
|
||||||
pdfFileId: createdPdfFileId,
|
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
} catch (err: any) {
|
return res.json({ jobId: job.id, status: "queued" });
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({
|
|
||||||
error: err.message || "Failed to forward to selenium agent",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if (result && result.ss_path) {
|
|
||||||
await emptyFolderContainingFile(result.ss_path);
|
|
||||||
} else {
|
|
||||||
console.log(`claim-status-check] no pdf_path available to cleanup`);
|
|
||||||
}
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error(
|
|
||||||
`[claim-status-check cleanup failed for ${result?.ss_path}`,
|
|
||||||
cleanupErr
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { forwardToPaymentOCRService } from "../services/paymentOCRService";
|
import { ocrQueue } from "../queue/queues";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// keep files in memory; FastAPI accepts them as multipart bytes
|
// keep files in memory; FastAPI accepts them as multipart bytes
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
|
const ALLOWED_MIMES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
"image/jpg",
|
||||||
|
]);
|
||||||
|
|
||||||
// POST /payment-ocr/extract (field name: "files")
|
// POST /payment-ocr/extract (field name: "files")
|
||||||
router.post(
|
router.post(
|
||||||
"/extract",
|
"/extract",
|
||||||
upload.array("files"), // allow multiple images
|
upload.array("files"),
|
||||||
async (req: Request, res: Response): Promise<any> => {
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
try {
|
|
||||||
const files = req.files as Express.Multer.File[] | undefined;
|
const files = req.files as Express.Multer.File[] | undefined;
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
@@ -21,29 +28,29 @@ router.post(
|
|||||||
.json({ error: "No image files uploaded. Use field name 'files'." });
|
.json({ error: "No image files uploaded. Use field name 'files'." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// (optional) basic client-side MIME guard
|
const bad = files.filter((f) => !ALLOWED_MIMES.has(f.mimetype.toLowerCase()));
|
||||||
const allowed = new Set([
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/tiff",
|
|
||||||
"image/bmp",
|
|
||||||
"image/jpg",
|
|
||||||
]);
|
|
||||||
const bad = files.filter((f) => !allowed.has(f.mimetype.toLowerCase()));
|
|
||||||
if (bad.length) {
|
if (bad.length) {
|
||||||
return res.status(415).json({
|
return res.status(415).json({
|
||||||
error: `Unsupported file types: ${bad
|
error: `Unsupported file types: ${bad.map((b) => b.originalname).join(", ")}`,
|
||||||
.map((b) => b.originalname)
|
|
||||||
.join(", ")}`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await forwardToPaymentOCRService(files);
|
const filesForQueue = files.map((f) => ({
|
||||||
return res.json({ rows });
|
originalname: f.originalname,
|
||||||
} catch (err) {
|
bufferBase64: f.buffer.toString("base64"),
|
||||||
console.error(err);
|
mimetype: f.mimetype,
|
||||||
return res.status(500).json({ error: "Payment OCR extraction failed" });
|
}));
|
||||||
}
|
|
||||||
|
const socketId: string | undefined =
|
||||||
|
(req.body?.socketId as string) ?? undefined;
|
||||||
|
|
||||||
|
const job = await ocrQueue.add("ocr", {
|
||||||
|
userId: (req.user as any)?.id ?? 0,
|
||||||
|
socketId,
|
||||||
|
files: filesForQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ jobId: job.id, status: "queued" });
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
|||||||
import {
|
import {
|
||||||
getPatientDocuments,
|
getPatientDocuments,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
viewDocument,
|
|
||||||
downloadDocument,
|
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
type PatientDocument
|
type PatientDocument
|
||||||
} from "@/lib/api/documents";
|
} from "@/lib/api/documents";
|
||||||
@@ -38,7 +36,6 @@ export default function DocumentsPage() {
|
|||||||
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
|
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null);
|
||||||
|
|
||||||
// pagination state for the expanded group
|
// pagination state for the expanded group
|
||||||
// pagination state
|
|
||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [limit, setLimit] = useState<number>(5);
|
const [limit, setLimit] = useState<number>(5);
|
||||||
const offset = (currentPage - 1) * limit;
|
const offset = (currentPage - 1) * limit;
|
||||||
@@ -46,11 +43,7 @@ export default function DocumentsPage() {
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
// Patient documents state
|
|
||||||
const [patientDocuments, setPatientDocuments] = useState<PatientDocument[]>([]);
|
|
||||||
const [patientDocumentsLoading, setPatientDocumentsLoading] = useState(false);
|
|
||||||
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
|
const [showPatientDocuments, setShowPatientDocuments] = useState(false);
|
||||||
const [documentThumbnails, setDocumentThumbnails] = useState<{ [key: number]: string }>({});
|
|
||||||
|
|
||||||
// Document preview state
|
// Document preview state
|
||||||
const [previewDocumentId, setPreviewDocumentId] = useState<number | null>(null);
|
const [previewDocumentId, setPreviewDocumentId] = useState<number | null>(null);
|
||||||
@@ -72,96 +65,50 @@ export default function DocumentsPage() {
|
|||||||
setLimit(5);
|
setLimit(5);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setTotalForExpandedGroup(null);
|
setTotalForExpandedGroup(null);
|
||||||
setShowPatientDocuments(false); // Reset documents toggle
|
setShowPatientDocuments(false);
|
||||||
|
|
||||||
// close the preview modal
|
|
||||||
setIsPreviewModalOpen(false);
|
setIsPreviewModalOpen(false);
|
||||||
setPreviewDocumentId(null);
|
setPreviewDocumentId(null);
|
||||||
|
|
||||||
// Load patient documents when patient is selected
|
|
||||||
if (selectedPatient?.id) {
|
|
||||||
console.log("Patient selected, loading documents for:", selectedPatient.id);
|
|
||||||
loadPatientDocuments(selectedPatient.id);
|
|
||||||
} else {
|
|
||||||
console.log("No patient selected, clearing documents");
|
|
||||||
setPatientDocuments([]);
|
|
||||||
}
|
|
||||||
}, [selectedPatient]);
|
}, [selectedPatient]);
|
||||||
|
|
||||||
// Load patient documents function
|
// Patient documents — React Query for caching (re-selecting same patient shows instantly)
|
||||||
const loadPatientDocuments = async (patientId: number) => {
|
const { data: patientDocuments = [], isLoading: patientDocumentsLoading } =
|
||||||
try {
|
useQuery<PatientDocument[]>({
|
||||||
setPatientDocumentsLoading(true);
|
queryKey: ["patientDocuments", selectedPatient?.id],
|
||||||
console.log("Loading documents for patient:", patientId);
|
enabled: !!selectedPatient?.id,
|
||||||
const response = await getPatientDocuments(patientId);
|
staleTime: 2 * 60 * 1000,
|
||||||
console.log("Loaded documents:", response);
|
queryFn: async () => {
|
||||||
if (response.success) {
|
const response = await getPatientDocuments(selectedPatient!.id);
|
||||||
setPatientDocuments(response.documents);
|
if (!response.success) throw new Error("Failed to load documents");
|
||||||
// Load thumbnails for image documents
|
return response.documents;
|
||||||
loadDocumentThumbnails(response.documents);
|
},
|
||||||
} else {
|
|
||||||
throw new Error("Failed to load documents");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load patient documents:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to load patient documents",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setPatientDocumentsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load thumbnails for image documents
|
// Derive thumbnails synchronously — no extra async round-trip
|
||||||
const loadDocumentThumbnails = async (documents: PatientDocument[]) => {
|
const documentThumbnails = useMemo(() => {
|
||||||
const thumbnails: { [key: number]: string } = {};
|
const result: { [key: number]: string } = {};
|
||||||
|
for (const doc of patientDocuments) {
|
||||||
for (const document of documents) {
|
if (doc.mimeType.startsWith("image/")) result[doc.id] = doc.filePath;
|
||||||
if (document.mimeType.startsWith('image/')) {
|
|
||||||
try {
|
|
||||||
// Use the document's filePath as the thumbnail URL
|
|
||||||
thumbnails[document.id] = document.filePath;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load thumbnail for document ${document.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}, [patientDocuments]);
|
||||||
|
|
||||||
setDocumentThumbnails(thumbnails);
|
// Listen for document upload events — invalidate React Query cache instead of manual refetch
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh patient documents (for after upload)
|
|
||||||
const refreshPatientDocuments = async () => {
|
|
||||||
if (selectedPatient?.id) {
|
|
||||||
await loadPatientDocuments(selectedPatient.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for document upload events
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDocumentUpload = (event: CustomEvent) => {
|
const refresh = () => {
|
||||||
console.log('Document upload event received:', event.detail);
|
if (selectedPatient?.id) {
|
||||||
refreshPatientDocuments();
|
queryClient.invalidateQueries({ queryKey: ["patientDocuments", selectedPatient.id] });
|
||||||
};
|
|
||||||
|
|
||||||
// Add event listener for document uploads
|
|
||||||
window.addEventListener('documentUploaded', handleDocumentUpload as EventListener);
|
|
||||||
|
|
||||||
// Also listen for storage events (for cross-tab communication)
|
|
||||||
const handleStorageChange = (e: StorageEvent) => {
|
|
||||||
if (e.key === 'documentUploaded' && e.newValue) {
|
|
||||||
refreshPatientDocuments();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('storage', handleStorageChange);
|
window.addEventListener("documentUploaded", refresh);
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === "documentUploaded" && e.newValue) refresh();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", handleStorageChange);
|
||||||
|
|
||||||
// Cleanup listeners
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('documentUploaded', handleDocumentUpload as EventListener);
|
window.removeEventListener("documentUploaded", refresh);
|
||||||
window.removeEventListener('storage', handleStorageChange);
|
window.removeEventListener("storage", handleStorageChange);
|
||||||
};
|
};
|
||||||
}, [selectedPatient]);
|
}, [selectedPatient]);
|
||||||
|
|
||||||
@@ -213,6 +160,7 @@ export default function DocumentsPage() {
|
|||||||
const { data: groups = [], isLoading: isLoadingGroups } = useQuery({
|
const { data: groups = [], isLoading: isLoadingGroups } = useQuery({
|
||||||
queryKey: ["groups", selectedPatient?.id],
|
queryKey: ["groups", selectedPatient?.id],
|
||||||
enabled: !!selectedPatient,
|
enabled: !!selectedPatient,
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiRequest(
|
const res = await apiRequest(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -380,9 +328,9 @@ export default function DocumentsPage() {
|
|||||||
{/* Existing Groups Section */}
|
{/* Existing Groups Section */}
|
||||||
<div>
|
<div>
|
||||||
{/* <h4 className="text-lg font-semibold mb-3">Document Groups</h4> */}
|
{/* <h4 className="text-lg font-semibold mb-3">Document Groups</h4> */}
|
||||||
{isLoadingGroups || patientDocumentsLoading ? (
|
{isLoadingGroups ? (
|
||||||
<div>Loading groups…</div>
|
<div>Loading groups…</div>
|
||||||
) : (groups as any[]).length === 0 && patientDocuments.length === 0 ? (
|
) : (groups as any[]).length === 0 && patientDocuments.length === 0 && !patientDocumentsLoading ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
No groups found.
|
No groups found.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user