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:
2026-07-17 14:55:38 -04:00
parent f29064883c
commit e49dfdbee9
6 changed files with 499 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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,
};
},
};