feat: add attachment upload to Edit Appointment form (disk, scanned, screenshot with crop)
Lets staff attach files to an appointment directly from the edit form via local disk upload, picking a previously-saved scanned attachment, or an inline screenshot with crop-before-save (matching the Copy Agent page's crop UX), instead of detouring through the Copy Agent page. Also fixes a latent data-loss bug in claim-form's save-for-appointment call, which replaced all AppointmentFile rows instead of merging with existing ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { z } from "zod";
|
||||
import multer from "multer";
|
||||
import {
|
||||
insertAppointmentSchema,
|
||||
updateAppointmentSchema,
|
||||
@@ -9,6 +10,20 @@ import {
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Reuses the same memory-storage + type/size limits as claims.ts's upload-to-cloud route
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit per file
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Unsupported file type"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Mirrors the same logic in claims.ts and appointmentTypeUtils.ts
|
||||
function inferApptType(codes: string[]): string | null {
|
||||
const priority = ["endo","implant","crown","pedo","dentures","extraction","perio","filling","ortho","recall","consultation","emergency"];
|
||||
@@ -473,6 +488,72 @@ router.patch("/:id/confirm", async (req: Request, res: Response): Promise<any> =
|
||||
}
|
||||
});
|
||||
|
||||
// Add a single attachment to an appointment (incremental insert, does not touch existing files)
|
||||
router.post(
|
||||
"/:appointmentId/files",
|
||||
upload.single("file"),
|
||||
async (req: Request, res: Response): Promise<any> => {
|
||||
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const appointmentIdParam = req.params.appointmentId;
|
||||
const appointmentId = parseInt(appointmentIdParam || "", 10);
|
||||
if (isNaN(appointmentId)) {
|
||||
return res.status(400).json({ error: "Invalid appointment ID" });
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
if (!file) return res.status(400).json({ error: "No file uploaded" });
|
||||
|
||||
try {
|
||||
const appointment = await storage.getAppointment(appointmentId);
|
||||
if (!appointment) {
|
||||
return res.status(404).json({ error: "Appointment not found" });
|
||||
}
|
||||
|
||||
const patient = await storage.getPatient(appointment.patientId);
|
||||
if (!patient) {
|
||||
return res.status(404).json({ error: "Patient not found" });
|
||||
}
|
||||
const patientName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`
|
||||
.trim()
|
||||
.replace(/[/\\?%*:|"<>]/g, "-") || "unknown";
|
||||
|
||||
const folder = await storage.getOrCreatePatientFolder(
|
||||
req.user.id,
|
||||
appointment.patientId,
|
||||
patientName
|
||||
);
|
||||
const attachmentsFolder = await storage.getOrCreateSubfolder(
|
||||
req.user.id,
|
||||
(folder as any).id,
|
||||
"Attachments"
|
||||
);
|
||||
|
||||
const cloudFile = await storage.initializeFileUpload(
|
||||
req.user.id,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
BigInt(file.size),
|
||||
1,
|
||||
(attachmentsFolder as any).id
|
||||
);
|
||||
await storage.appendFileChunk((cloudFile as any).id, 0, file.buffer);
|
||||
const finalized = await storage.finalizeFileUpload((cloudFile as any).id);
|
||||
|
||||
const created = await storage.addAppointmentFile(appointmentId, {
|
||||
filename: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
filePath: finalized.diskPath,
|
||||
});
|
||||
|
||||
return res.json({ error: false, data: created });
|
||||
} catch (err: any) {
|
||||
console.error("[appointment-files]", err);
|
||||
return res.status(500).json({ error: "Failed to upload file", message: err?.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete an appointment
|
||||
router.delete("/:id", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface IAppointmentProceduresStorage {
|
||||
deleteProcedure(id: number): Promise<void>;
|
||||
clearByAppointmentId(appointmentId: number): Promise<void>;
|
||||
getAppointmentFiles(appointmentId: number): Promise<AppointmentFileMeta[]>;
|
||||
addAppointmentFile(
|
||||
appointmentId: number,
|
||||
file: AppointmentFileMeta
|
||||
): Promise<AppointmentFileMeta & { id: number }>;
|
||||
getAppointmentIdsWithProcedures(ids: number[]): Promise<Set<number>>;
|
||||
getProcedureCodesByAppointmentIds(ids: number[]): Promise<Map<number, string[]>>;
|
||||
}
|
||||
@@ -201,4 +205,24 @@ export const appointmentProceduresStorage: IAppointmentProceduresStorage = {
|
||||
filePath: f.filePath,
|
||||
}));
|
||||
},
|
||||
|
||||
async addAppointmentFile(
|
||||
appointmentId: number,
|
||||
file: AppointmentFileMeta
|
||||
): Promise<AppointmentFileMeta & { id: number }> {
|
||||
const row = await db.appointmentFile.create({
|
||||
data: {
|
||||
appointmentId,
|
||||
filename: file.filename,
|
||||
mimeType: file.mimeType ?? null,
|
||||
filePath: file.filePath ?? null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
filename: row.filename,
|
||||
mimeType: row.mimeType,
|
||||
filePath: row.filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user