fix: persist and correctly forward appointment attachments to insurance claims

- Add "surg ext" alias mapping to D7210 in the AI CDT lookup.
- Add GET/DELETE endpoints for appointment attachments, and load existing
  attachments in the appointment edit modal (they were uploaded correctly
  but never re-fetched, so they appeared to vanish on reopen).
- Fix claim-form.tsx so previously-saved appointment attachments are always
  loaded (even when a claim already exists) instead of only when creating a
  brand-new claim, preventing them from being silently wiped on save.
- Merge existing appointment attachments into every insurance submit handler
  (MassHealth, CCA, DDMA, UnitedDH, TuftsSCO) so Selenium submission always
  includes files saved earlier via the Schedule editor, not just files
  uploaded in the current claim-form session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 21:03:32 -04:00
parent b1932fa3d6
commit a42ef5f570
6 changed files with 199 additions and 34 deletions

View File

@@ -125,6 +125,7 @@ const ALIAS_MAP: Record<string, string> = {
"simple extraction": "extraction erupted",
"surgical extraction": "extraction bone",
"surgical ext": "extraction bone",
"surg ext": "extraction bone",
"baby tooth ext": "extraction primary",
"baby tooth extraction": "extraction primary",
"primary tooth ext": "extraction primary",
@@ -211,6 +212,7 @@ const DIRECT_CODE_MAP: Record<string, string> = {
"primary tooth ext": "D7111",
"surgical extraction": "D7210",
"surgical ext": "D7210",
"surg ext": "D7210",
"soft tissue impaction": "D7220",
"partial bony": "D7230",
"complete bony": "D7240",

View File

@@ -489,6 +489,27 @@ router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> =
}
});
// List attachments for an appointment
router.get(
"/:appointmentId/files",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
if (isNaN(appointmentId)) {
return res.status(400).json({ error: "Invalid appointment ID" });
}
try {
const files = await storage.getAppointmentFiles(appointmentId);
return res.json({ error: false, data: files });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to load attachments", message: err?.message });
}
}
);
// Add a single attachment to an appointment (incremental insert, does not touch existing files)
router.post(
"/:appointmentId/files",
@@ -555,6 +576,29 @@ router.post(
}
);
// Delete a single attachment from an appointment
router.delete(
"/:appointmentId/files/:fileId",
async (req: Request, res: Response): Promise<any> => {
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
const appointmentId = parseInt(req.params.appointmentId || "", 10);
const fileId = parseInt(req.params.fileId || "", 10);
if (isNaN(appointmentId) || isNaN(fileId)) {
return res.status(400).json({ error: "Invalid appointment or file ID" });
}
try {
const deleted = await storage.deleteAppointmentFile(appointmentId, fileId);
if (!deleted) return res.status(404).json({ error: "Attachment not found" });
return res.json({ error: false });
} catch (err: any) {
console.error("[appointment-files]", err);
return res.status(500).json({ error: "Failed to delete attachment", message: err?.message });
}
}
);
// Delete an appointment
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
try {

View File

@@ -860,7 +860,11 @@ router.post(
// straight from disk via filePath (unlike MH/CCA, which get base64 buffers above).
const diskClaimFiles = allFileMeta.filter((f) => {
if (!f.filePath) return false;
return fs.existsSync(path.join(process.cwd(), f.filePath));
const exists = fs.existsSync(path.join(process.cwd(), f.filePath));
if (!exists) {
console.warn(`[batch-column] attachment not found on disk: ${path.join(process.cwd(), f.filePath)}`);
}
return exists;
});
// Base claim data shared across all insurance pathways

View File

@@ -6,6 +6,8 @@ import {
UpdateAppointmentProcedure,
} from "@repo/db/types";
import { prisma as db } from "@repo/db/client";
import fs from "fs";
import path from "path";
export interface AppointmentFileMeta {
filename: string;
@@ -50,6 +52,7 @@ export interface IAppointmentProceduresStorage {
appointmentId: number,
file: AppointmentFileMeta
): Promise<AppointmentFileMeta & { id: number }>;
deleteAppointmentFile(appointmentId: number, fileId: number): Promise<boolean>;
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
}
@@ -225,4 +228,30 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
filePath: row.filePath,
};
},
async deleteAppointmentFile(appointmentId: number, fileId: number): Promise<boolean> {
const file = await db.appointmentFile.findFirst({
where: { id: fileId, appointmentId },
});
if (!file) return false;
await db.appointmentFile.delete({ where: { id: fileId } });
// Best-effort cleanup of the underlying cloud-storage file/disk copy.
if (file.filePath) {
const cloudFile = await db.cloudFile.findFirst({
where: { diskPath: file.filePath },
select: { id: true, diskPath: true },
});
if (cloudFile) {
if (cloudFile.diskPath) {
const abs = path.join(process.cwd(), cloudFile.diskPath);
if (fs.existsSync(abs)) fs.unlinkSync(abs);
}
await db.cloudFile.delete({ where: { id: cloudFile.id } }).catch(() => null);
}
}
return true;
},
};