fix: fix remote browser socket connection and related updates

This commit is contained in:
Gitead
2026-04-30 11:52:58 -04:00
parent 441cfcc8e3
commit d8f852741a
959 changed files with 13338 additions and 2208 deletions

View File

@@ -84,7 +84,7 @@ router.put("/set-npi-provider/:appointmentId", async (req: Request, res: Respons
*/
router.post("/save-for-appointment", async (req: Request, res: Response) => {
try {
const { appointmentId, patientId, npiProviderId, procedures } = req.body;
const { appointmentId, patientId, npiProviderId, procedures, attachments } = req.body;
if (!appointmentId || isNaN(Number(appointmentId))) {
return res.status(400).json({ message: "Invalid appointmentId" });
@@ -100,6 +100,14 @@ router.post("/save-for-appointment", async (req: Request, res: Response) => {
(p) => String(p.procedureCode ?? "").trim() !== ""
);
const validAttachments = Array.isArray(attachments)
? (attachments as any[]).filter((a) => a?.filename).map((a) => ({
filename: String(a.filename),
mimeType: a.mimeType ?? null,
filePath: a.filePath ?? null,
}))
: undefined;
const count = await storage.saveForAppointment({
appointmentId: Number(appointmentId),
patientId: Number(patientId),
@@ -110,6 +118,7 @@ router.post("/save-for-appointment", async (req: Request, res: Response) => {
toothNumber: p.toothNumber || null,
toothSurface: p.toothSurface || null,
})),
attachments: validAttachments,
});
return res.json({ success: true, count });

View File

@@ -425,18 +425,25 @@ router.post(
// Fetch active claim for this appointment (includes service lines from draft saves)
const activeClaim = await storage.getActiveClaimByAppointmentId(Number(apt.id));
// "Already claimed" = has a real claim number OR status is REVIEW/APPROVED
// A PENDING claim with no claimNumber is just a draft save — not yet submitted
const alreadyClaimed =
// Skip if claim was voided via the "Void" button in Select Procedures.
if (activeClaim?.status === "VOID") {
resultItem.skipped = true;
resultItem.error = "Voided";
results.push(resultItem);
continue;
}
// Skip appointments whose claim was already submitted (has claimNumber or REVIEW/APPROVED).
// The "Update & Resubmit" button resets the claim to PENDING so it is picked up again.
const alreadySubmitted =
activeClaim &&
((activeClaim.claimNumber != null &&
String(activeClaim.claimNumber).trim() !== "") ||
((activeClaim.claimNumber != null && String(activeClaim.claimNumber).trim() !== "") ||
activeClaim.status === "REVIEW" ||
activeClaim.status === "APPROVED");
if (alreadyClaimed) {
if (alreadySubmitted) {
resultItem.skipped = true;
resultItem.error = "Already claimed";
resultItem.error = "Already submitted";
results.push(resultItem);
continue;
}
@@ -544,7 +551,6 @@ router.post(
let claimId: number;
if (activeClaim?.id) {
claimId = activeClaim.id;
// Update claim's npiProviderId if the user chose a different provider via Select Procedures
if (procNpiProviderId && activeClaim.npiProviderId !== procNpiProviderId) {
await storage.updateClaim(claimId, { npiProviderId: procNpiProviderId });
}
@@ -634,12 +640,31 @@ router.post(
};
}
// Collect attachments: appointment-level files + claim-level files
const apptFiles = await storage.getAppointmentFiles(Number(apt.id));
const claimFiles = (activeClaim as any)?.claimFiles ?? [];
const allFileMeta = [
...apptFiles,
...claimFiles,
] as Array<{ filename: string; mimeType?: string | null; filePath?: string | null }>;
const filesForQueue = allFileMeta.flatMap((f) => {
if (!f.filePath) return [];
const absPath = path.join(process.cwd(), f.filePath);
if (!fs.existsSync(absPath)) {
console.warn(`[batch-column] attachment not found on disk: ${absPath}`);
return [];
}
const bufferBase64 = fs.readFileSync(absPath).toString("base64");
return [{ originalname: f.filename, bufferBase64, mimetype: f.mimeType ?? "application/octet-stream" }];
});
// Enqueue selenium claim-submit job
const job = await seleniumQueue.add("claim-submit", {
jobType: "claim-submit",
userId: req.user.id,
enrichedPayload,
files: [],
files: filesForQueue,
claimId,
});
@@ -991,4 +1016,72 @@ router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
}
});
// POST /api/claims/void-for-appointment
// Marks the claim for an appointment as VOID so batch-column skips it permanently.
// If no claim exists yet, creates a minimal placeholder VOID claim.
router.post("/void-for-appointment", async (req: Request, res: Response): Promise<any> => {
try {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const { appointmentId } = req.body;
if (!appointmentId || isNaN(Number(appointmentId))) {
return res.status(400).json({ error: "Invalid appointmentId" });
}
const existing = await storage.getActiveClaimByAppointmentId(Number(appointmentId));
if (existing) {
await storage.updateClaim(Number(existing.id), { status: "VOID" } as any);
return res.json({ voided: true, claimId: existing.id });
}
// No claim yet — look up appointment + patient to create a minimal VOID placeholder
const apt = await storage.getAppointment(Number(appointmentId));
if (!apt) return res.status(404).json({ error: "Appointment not found" });
const patient = apt.patientId ? await storage.getPatient(apt.patientId) : null;
if (!patient) return res.status(404).json({ error: "Patient not found" });
const newClaim = await storage.createClaim({
patientId: Number(patient.id),
appointmentId: Number(appointmentId),
userId: req.user.id,
staffId: Number(apt.staffId),
patientName: `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim(),
memberId: String(patient.insuranceId ?? ""),
dateOfBirth: patient.dateOfBirth ? new Date(patient.dateOfBirth) : new Date(),
serviceDate: apt.date instanceof Date ? apt.date : new Date(apt.date as any),
insuranceProvider: "MassHealth",
remarks: "",
missingTeethStatus: "No_missing",
missingTeeth: {},
status: "VOID",
} as any);
return res.json({ voided: true, claimId: newClaim.id });
} catch (err: any) {
console.error("void-for-appointment error", err);
return res.status(500).json({ error: err.message ?? "Server error" });
}
});
// POST /api/claims/reset-for-resubmit
// Resets the active claim for an appointment back to PENDING with no claimNumber,
// so the batch-column will pick it up again on the next run.
router.post("/reset-for-resubmit", async (req: Request, res: Response): Promise<any> => {
try {
const { appointmentId } = req.body;
if (!appointmentId || isNaN(Number(appointmentId))) {
return res.status(400).json({ error: "Invalid appointmentId" });
}
const claim = await storage.getActiveClaimByAppointmentId(Number(appointmentId));
if (!claim) {
return res.json({ reset: false, message: "No existing claim found — will be created fresh on next submit" });
}
await storage.updateClaim(Number(claim.id), { status: "PENDING", claimNumber: null } as any);
return res.json({ reset: true, claimId: claim.id });
} catch (err: any) {
console.error("reset-for-resubmit error", err);
return res.status(500).json({ error: err.message ?? "Server error" });
}
});
export default router;

View File

@@ -7,6 +7,12 @@ import {
} from "@repo/db/types";
import { prisma as db } from "@repo/db/client";
export interface AppointmentFileMeta {
filename: string;
mimeType?: string | null;
filePath?: string | null;
}
export interface IAppointmentProceduresStorage {
getByAppointmentId(appointmentId: number): Promise<AppointmentProcedure[]>;
getPrefillDataByAppointmentId(appointmentId: number): Promise<{
@@ -14,6 +20,7 @@ export interface IAppointmentProceduresStorage {
patient: Patient;
procedures: AppointmentProcedure[];
npiProviderId: number | null;
appointmentFiles: AppointmentFileMeta[];
} | null>;
saveForAppointment(params: {
appointmentId: number;
@@ -25,6 +32,7 @@ export interface IAppointmentProceduresStorage {
toothNumber?: string | null;
toothSurface?: string | null;
}>;
attachments?: AppointmentFileMeta[];
}): Promise<number>;
createProcedure(
@@ -37,6 +45,7 @@ export interface IAppointmentProceduresStorage {
): Promise<AppointmentProcedure>;
deleteProcedure(id: number): Promise<void>;
clearByAppointmentId(appointmentId: number): Promise<void>;
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
}
@@ -58,6 +67,9 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
procedures: {
orderBy: { createdAt: "asc" },
},
files: {
orderBy: { id: "asc" },
},
},
});
@@ -72,11 +84,28 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
patient: appointment.patient,
procedures: appointment.procedures,
npiProviderId,
appointmentFiles: (appointment.files as any[]).map((f) => ({
id: f.id,
filename: f.filename,
mimeType: f.mimeType,
filePath: f.filePath,
})),
};
},
async saveForAppointment({ appointmentId, patientId, npiProviderId, procedures }) {
async saveForAppointment({ appointmentId, patientId, npiProviderId, procedures, attachments }) {
await db.appointmentProcedure.deleteMany({ where: { appointmentId } });
if (attachments?.length) {
await db.appointmentFile.deleteMany({ where: { appointmentId } });
await db.appointmentFile.createMany({
data: attachments.map((a) => ({
appointmentId,
filename: a.filename,
mimeType: a.mimeType ?? null,
filePath: a.filePath ?? null,
})),
});
}
if (!procedures.length) return 0;
const result = await db.appointmentProcedure.createMany({
data: procedures.map((p) => ({
@@ -139,6 +168,19 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
select: { appointmentId: true },
distinct: ["appointmentId"],
});
return new Set(rows.map((r) => r.appointmentId));
return new Set(rows.map((r: any) => r.appointmentId));
},
async getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]> {
const rows = await db.appointmentFile.findMany({
where: { appointmentId },
orderBy: { id: "asc" },
});
return rows.map((f: any) => ({
id: f.id,
filename: f.filename,
mimeType: f.mimeType,
filePath: f.filePath,
}));
},
};

View File

@@ -60,7 +60,7 @@ export const claimsStorage: IStorage = {
return db.claim.findFirst({
where: {
appointmentId,
status: { notIn: ["CANCELLED", "VOID"] },
status: { notIn: ["CANCELLED"] },
},
orderBy: { createdAt: "desc" },
include: { serviceLines: true, claimFiles: true, staff: true },

View File

@@ -402,6 +402,9 @@ export function ClaimForm({
setForm((prev) => ({
...prev,
serviceLines: mappedLines,
...(data.appointmentFiles?.length
? { claimFiles: data.appointmentFiles }
: {}),
}));
// Restore NPI provider from saved procedures
@@ -1094,6 +1097,10 @@ export function ClaimForm({
: null;
try {
const attachments = form.uploadedFiles?.length
? await uploadAttachmentsToLocalFolder(form.uploadedFiles)
: [];
const res = await apiRequest("POST", "/api/appointment-procedures/save-for-appointment", {
appointmentId,
patientId,
@@ -1104,16 +1111,42 @@ export function ClaimForm({
toothNumber: l.toothNumber || null,
toothSurface: l.toothSurface || null,
})),
attachments,
});
const data = await res.json();
if (!data.success) throw new Error("Failed to save procedures");
toast({ title: "Procedures saved", description: `${data.count} procedure(s) saved.` });
const attachMsg = attachments.length ? ` and ${attachments.length} attachment(s)` : "";
toast({ title: "Procedures saved", description: `${data.count} procedure(s)${attachMsg} saved.` });
onClose();
} catch (err: any) {
toast({ title: "Save failed", description: err?.message ?? "Failed to save procedures.", variant: "destructive" });
}
};
// Same as handleProceduresSave but also resets any existing submitted claim so
// batch-column will treat this appointment as needing a new submission.
const handleProceduresUpdate = async () => {
if (!appointmentId) return;
try {
await apiRequest("POST", "/api/claims/reset-for-resubmit", { appointmentId });
} catch {
// Non-fatal: if reset fails we still save procedures
}
await handleProceduresSave();
};
// Marks the claim for this appointment as VOID so batch-column will always skip it.
const handleProceduresVoid = async () => {
if (!appointmentId) return;
try {
await apiRequest("POST", "/api/claims/void-for-appointment", { appointmentId });
toast({ title: "Claim voided", description: "This appointment will be skipped when claiming for the column." });
onClose();
} catch (err: any) {
toast({ title: "Void failed", description: err?.message ?? "Failed to void claim.", variant: "destructive" });
}
};
// for direct combo button.
const applyComboAndThenMH = async (
comboId: keyof typeof PROCEDURE_COMBOS,
@@ -1720,15 +1753,29 @@ export function ClaimForm({
{proceduresOnly ? "Save Procedures" : "Insurance Carriers"}
</h3>
{proceduresOnly ? (
/* ── Select Procedures mode: Save only ── */
<div className="flex justify-center">
/* ── Select Procedures mode ── */
<div className="flex justify-center gap-3">
<Button
className="w-48"
className="w-40"
variant="default"
onClick={handleProceduresSave}
>
Save Procedures
</Button>
<Button
className="w-40"
variant="secondary"
onClick={handleProceduresUpdate}
>
Update &amp; Resubmit
</Button>
<Button
className="w-28"
variant="destructive"
onClick={handleProceduresVoid}
>
Void
</Button>
</div>
) : (
/* ── Insurance Claim mode: submit buttons, no Save ── */

View File

@@ -8,8 +8,11 @@ import { io, Socket } from "socket.io-client";
// Connect directly to backend to avoid Vite's WS proxy failing on upgrade,
// which causes an unhandled AggregateError from engine.io's Promise.any() probe.
// Use the env var when set; otherwise derive the backend URL from the current
// page's hostname so remote browsers (non-localhost) reach the server correctly.
const SOCKET_URL =
import.meta.env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000";
import.meta.env.VITE_API_BASE_URL_BACKEND ||
`${window.location.protocol}//${window.location.hostname}:5000`;
export const socket: Socket = io(SOCKET_URL, {
withCredentials: true,

View File

@@ -470,9 +470,6 @@ export default function InsuranceStatusPage() {
{/* Insurance Eligibility Check Form */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Check Insurance Eligibility</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 md:grid-cols-4 gap-4 mb-4">
<div className="space-y-2">

View File

@@ -13,6 +13,7 @@ export default defineConfig(({ mode }) => {
fs: {
allow: [".."],
},
allowedHosts: ["communitydentistsoflowell.mydentalofficemanagement.com"],
proxy: {
"/api": {
target: env.VITE_API_BASE_URL_BACKEND || "http://localhost:5000",