feat: save claim attachments to cloud storage and documents page
- Claim file uploads (chatbot or manual) now save to both the Cloud Storage patient folder and the Documents page via new POST /api/claims/upload-to-cloud endpoint - MH submit flow now calls uploadAttachmentsToLocalFolder (same as DDMA/United/Tufts) so chatbot-attached X-rays are persisted - Removed old /upload-attachments disk route and attachmentDiskStorage multer config; deleted uploads/patients/ folder - uploadAttachmentsToLocalFolder now points to /upload-to-cloud and sends patientId so the backend can create the patient folder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,14 @@ Intents:
|
||||
e.g. "check Maria Jesus", "verify insurance for John Smith"
|
||||
- eligibility_by_id : user provides a member ID and date of birth (no patient name)
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988"
|
||||
- check_and_claim : user wants to check eligibility AND submit procedures as claims
|
||||
ALSO use this when user wants to check eligibility AND schedule/add an appointment on a date
|
||||
e.g. "check mh for 100xxxx, 10/10/1988 and schedule on 4/10/2026"
|
||||
e.g. "check mh for 100xxxx, 10/10/1988 and make appointment on 5/1/2026"
|
||||
In these cases set appointmentDate to the mentioned date (YYYY-MM-DD)
|
||||
- check_and_claim : user wants to check eligibility AND submit PROCEDURES/BILLING as claims
|
||||
e.g. "check masshealth for 100xxxx, 10/10/1988 and claim perio exam and adult cleaning"
|
||||
e.g. "check Maria Jesus and claim D0120 D1110"
|
||||
Only use this when procedures or CDT codes are mentioned — NOT for scheduling
|
||||
- find_patient : look up a patient record only, no eligibility
|
||||
e.g. "find patient John", "look up Smith"
|
||||
- schedule_appointment : add a patient to the schedule (today or a specified date/time)
|
||||
@@ -84,6 +89,11 @@ Intents:
|
||||
Rules:
|
||||
- For check_and_claim and claim_only, procedureNames should be the RAW user text
|
||||
(e.g. "perio exam", "adult cleaning", "D0120") — do NOT translate to codes
|
||||
- IMPORTANT: If the user says "with the x ray", "with x ray", "attach x ray", "the x ray", "with xray",
|
||||
"with the attachment", "the attachment", "with attachment", "with the file", "with the image",
|
||||
"with the scan", "with the photo", "with the document", or any variation meaning an uploaded/attached file,
|
||||
do NOT include it in procedureNames. It refers to a file attachment, not a billable procedure.
|
||||
Only include actual clinical procedures in procedureNames.
|
||||
- For composite fillings with a tooth number, preserve the EXACT notation including tooth# and surfaces:
|
||||
e.g. "composite #29 O", "#8 MO", "composite #11 MOD" — keep the #number and surface letters together as one entry
|
||||
- For RCT/root canal with a tooth number, preserve the tooth# in the entry:
|
||||
|
||||
@@ -11,6 +11,13 @@ import { ChatClassification } from "./internal-chat-graph";
|
||||
import { lookupCdtCodes } from "./cdt-lookup";
|
||||
import insuranceAliases from "../data/insuranceAliases.json";
|
||||
|
||||
// Phrases the user may write to mean "attach the uploaded file" — not CDT procedures
|
||||
const ATTACHMENT_PHRASES = /^(with\s+)?(the\s+)?(x[\s-]?ray[s]?|xray[s]?|radiograph[s]?|image[s]?|film[s]?|attachment[s]?|attach|file[s]?|photo[s]?|picture[s]?|scan[s]?|doc(ument)?[s]?)$/i;
|
||||
|
||||
function stripAttachmentRefs(names: string[]): string[] {
|
||||
return names.filter((n) => !ATTACHMENT_PHRASES.test(n.trim()));
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedPatient {
|
||||
@@ -358,6 +365,7 @@ async function handleEligibilityById(
|
||||
dob: resolvedDob,
|
||||
siteKey,
|
||||
autoCheck: siteKeyToAutoCheck(siteKey),
|
||||
appointmentDate: c.appointmentDate ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -423,7 +431,7 @@ async function handleCheckAndClaim(
|
||||
}
|
||||
|
||||
// 3. Map procedure names → CDT codes (custom aliases take priority)
|
||||
const procedureNames = c.procedureNames ?? [];
|
||||
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
|
||||
const cdtResults: CdtResult[] = procedureNames.length > 0
|
||||
? lookupCdtCodes(procedureNames, customAliases)
|
||||
: [];
|
||||
@@ -492,7 +500,7 @@ async function handleClaimOnly(
|
||||
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
|
||||
|
||||
// Map procedure names → CDT codes
|
||||
const procedureNames = c.procedureNames ?? [];
|
||||
const procedureNames = stripAttachmentRefs(c.procedureNames ?? []);
|
||||
if (procedureNames.length === 0) {
|
||||
return { reply: "Please specify which procedures to claim (e.g. perio exam, adult prophy)." };
|
||||
}
|
||||
@@ -741,15 +749,16 @@ async function handleScheduleAppointment(
|
||||
export async function createAppointmentToday(
|
||||
patientId: number,
|
||||
userId: number,
|
||||
storage: StorageLike
|
||||
storage: StorageLike,
|
||||
targetDate?: string // YYYY-MM-DD; defaults to today
|
||||
): Promise<{ startTime: string; endTime: string; dateStr: string; dateLabel: string; column: string } | { error: string }> {
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
const dateLabel = today.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
const localDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const base = targetDate ? new Date(targetDate + "T00:00:00") : new Date();
|
||||
const dateStr = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, "0")}-${String(base.getDate()).padStart(2, "0")}`;
|
||||
const dateLabel = base.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
const localDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
|
||||
|
||||
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||
const dayName = dayNames[today.getDay()]!;
|
||||
const dayName = dayNames[base.getDay()]!;
|
||||
|
||||
const officeHours = await storage.getOfficeHours(userId);
|
||||
const dayHours = officeHours?.data?.doctors?.[dayName] ?? {
|
||||
@@ -757,7 +766,7 @@ export async function createAppointmentToday(
|
||||
};
|
||||
|
||||
if (!dayHours.enabled) {
|
||||
return { error: `The office is closed today (${dayName}). Cannot create appointment.` };
|
||||
return { error: `The office is closed on ${dayName} (${dateLabel}). Cannot create appointment.` };
|
||||
}
|
||||
|
||||
const allSlots = buildSlots(dayHours);
|
||||
|
||||
@@ -78,7 +78,7 @@ export const startBackupCron = () => {
|
||||
const log = await cronJobLogStorage.createJobLog("local-backup", startedAt);
|
||||
|
||||
try {
|
||||
const filename = `dental_backup_${Date.now()}.sql`;
|
||||
const filename = `dental_backup_${Date.now()}.zip`;
|
||||
await backupDatabaseToPath({ destinationPath: LOCAL_BACKUP_DIR, filename });
|
||||
pruneOldBackups(LOCAL_BACKUP_DIR);
|
||||
await storage.createBackup(admin.id);
|
||||
@@ -153,7 +153,7 @@ export const startBackupCron = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const filename = `dental_backup_usb_${Date.now()}.sql`;
|
||||
const filename = `dental_backup_usb_${Date.now()}.zip`;
|
||||
await backupDatabaseToPath({ destinationPath: usbBackupPath, filename });
|
||||
pruneOldBackups(usbBackupPath);
|
||||
await storage.createBackup(admin.id);
|
||||
|
||||
@@ -313,9 +313,9 @@ router.post("/create-appointment-today", async (req: Request, res: Response): Pr
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const { patientId } = req.body;
|
||||
const { patientId, date } = req.body;
|
||||
if (!patientId) return res.status(400).json({ message: "patientId is required" });
|
||||
const result = await createAppointmentToday(Number(patientId), userId, storage);
|
||||
const result = await createAppointmentToday(Number(patientId), userId, storage, date ?? undefined);
|
||||
if ("error" in result) return res.status(409).json({ message: result.error });
|
||||
return res.status(200).json(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -90,45 +90,66 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
// Disk-storage uploader for claim attachments saved under uploads/patients/<name>/
|
||||
const attachmentDiskStorage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
|
||||
const dir = path.join(process.cwd(), "uploads", "patients", patientName);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
const base = path.basename(file.originalname, ext).replace(/\s+/g, "_");
|
||||
cb(null, `${Date.now()}_${base}${ext}`);
|
||||
},
|
||||
});
|
||||
const attachmentUpload = multer({
|
||||
storage: attachmentDiskStorage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||
cb(null, allowed.includes(file.mimetype));
|
||||
},
|
||||
});
|
||||
|
||||
// POST /api/claims/upload-attachments
|
||||
// Saves files to uploads/patients/<patientName>/ and returns their paths.
|
||||
// POST /api/claims/upload-to-cloud
|
||||
// Saves uploaded files to both:
|
||||
// - Patient Documents (visible on Documents page)
|
||||
// - Cloud Storage patient folder (visible on Cloud Storage page)
|
||||
router.post(
|
||||
"/upload-attachments",
|
||||
attachmentUpload.array("files", 10),
|
||||
(req: Request, res: Response): any => {
|
||||
"/upload-to-cloud",
|
||||
upload.array("files", 10),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: true, message: "No files uploaded" });
|
||||
if (!files?.length) return res.status(400).json({ error: "No files uploaded" });
|
||||
|
||||
const result = files.map((f) => ({
|
||||
filename: f.originalname,
|
||||
mimeType: f.mimetype,
|
||||
filePath: `/uploads/patients/${path.basename(path.dirname(f.path))}/${path.basename(f.path)}`,
|
||||
}));
|
||||
const patientId = Number(req.body.patientId);
|
||||
const patientName = String(req.body.patientName || "unknown").replace(/[/\\?%*:|"<>]/g, "-").trim();
|
||||
|
||||
return res.json({ error: false, data: result });
|
||||
if (!patientId || isNaN(patientId)) {
|
||||
return res.status(400).json({ error: "Invalid patientId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const folder = await storage.getOrCreatePatientFolder(req.user.id, patientId, patientName);
|
||||
const result: { filename: string; mimeType: string; filePath: string }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// Save to patient-documents (Documents page)
|
||||
await storage.createPatientDocument(
|
||||
patientId,
|
||||
file.originalname,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
file.buffer
|
||||
);
|
||||
|
||||
// Save to cloud storage patient folder (Cloud Storage page)
|
||||
const cloudFile = await storage.initializeFileUpload(
|
||||
req.user.id,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
BigInt(file.size),
|
||||
1,
|
||||
(folder as any).id
|
||||
);
|
||||
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
|
||||
await storage.finalizeFileUpload((cloudFile as any).id);
|
||||
|
||||
result.push({
|
||||
filename: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
filePath: `/api/cloud-storage/files/${(cloudFile as any).id}/content`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ error: false, data: result });
|
||||
} catch (err: any) {
|
||||
console.error("[upload-to-cloud]", err);
|
||||
return res.status(500).json({ error: "Failed to upload files", message: err?.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -370,7 +370,7 @@ router.post("/backup-path", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const filename = `dental_backup_${Date.now()}.sql`;
|
||||
const filename = `dental_backup_${Date.now()}.zip`;
|
||||
|
||||
try {
|
||||
await backupDatabaseToPath({
|
||||
|
||||
Reference in New Issue
Block a user