feat: patient & lab file attachments (real backend storage)

Add a real file-storage layer to the backend and wire upload UI into the
frontend.

backend:
- new `attachments` table (org-scoped, links to a patient file number and
  optionally a lab result) + Drizzle migration
- `/api/attachments` route: upload (multer → disk under UPLOAD_DIR),
  list, stream/download, delete; gated by patient:write OR lab:write via
  a new requireAnyPermission helper so lab staff can attach analyses
- UPLOAD_DIR env (default ./uploads) + a persistent docker volume

frontend:
- lib/attachments.ts client (multipart upload, list, delete, preview URL)
- staged file picker in the patient Add/Edit dialog (uploaded after save)
  and the lab Add-result dialog (linked to the result)
- a Files section in the patient sheet that lists attachments and opens
  them in a preview dialog (images inline, others via download)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-18 20:04:03 +03:00
parent 67cafdac3d
commit b1abb29108
21 changed files with 4574 additions and 14 deletions
+42
View File
@@ -0,0 +1,42 @@
import {
index,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Files uploaded against a patient record (and optionally a specific lab
// result). Scoped to a clinic (organization). The bytes live on disk under
// UPLOAD_DIR (see src/services/attachments.ts); this table only holds metadata
// plus the relative `storagePath`.
// fileNumber → the patient's MRN this file belongs to.
// labKey → set when the file documents a specific lab result.
export const attachments = pgTable(
"attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
fileNumber: text("file_number"),
labKey: text("lab_key"),
filename: text("filename").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
storagePath: text("storage_path").notNull(),
uploadedByUserId: text("uploaded_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("attachments_org_file_idx").on(
table.organizationId,
table.fileNumber,
),
],
);
+1
View File
@@ -14,3 +14,4 @@ export * from "./settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
export * from "./attachments.js";
+3
View File
@@ -16,6 +16,9 @@ const schema = z.object({
.string()
.min(1)
.default("dev-insecure-ai-key-change-me"),
// Directory where uploaded patient/lab files are stored on disk. Back this
// with a persistent volume in production (see docker-compose.yml).
UPLOAD_DIR: z.string().min(1).default("./uploads"),
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
PORT: z.coerce.number().int().positive().default(4000),
+3
View File
@@ -11,6 +11,7 @@ import { initRealtime } from "./realtime.js";
import { activityRouter } from "./routes/activity.js";
import { aiRouter } from "./routes/ai.js";
import { analyticsRouter } from "./routes/analytics.js";
import { attachmentsRouter } from "./routes/attachments.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
@@ -63,6 +64,7 @@ app.get("/health", (_req, res) => {
});
app.use("/api/patients", patientsRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
@@ -90,6 +92,7 @@ server.listen(env.PORT, () => {
console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`);
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
console.log(` • patients: /api/patients`);
console.log(` • files: /api/attachments`);
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
+36
View File
@@ -100,3 +100,39 @@ export function requirePermission(permission: PermissionRequest) {
}
};
}
// Gates a route on holding ANY of several permissions (logical OR) — e.g. an
// attachment may be uploaded by a clinician (patient:write) OR by lab staff
// (lab:write). Passes if the caller's role(s) satisfy at least one request.
export function requireAnyPermission(...permissions: PermissionRequest[]) {
return async (
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> => {
try {
const names = String(req.memberRole ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
let allowed = false;
outer: for (const permission of permissions) {
for (const name of names) {
const role = roles[name as keyof typeof roles];
if (role && (await role.authorize(permission)).success) {
allowed = true;
break outer;
}
}
}
if (!allowed) {
throw new HttpError(403, "You don't have permission to do that.");
}
next();
} catch (err) {
next(err);
}
};
}
+202
View File
@@ -0,0 +1,202 @@
import path from "node:path";
import { Router } from "express";
import multer from "multer";
import { nanoid } from "nanoid";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import {
requireAnyPermission,
requireAuth,
requireOrg,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import {
createAttachment,
deleteAttachment,
ensureUploadDir,
getAttachmentRow,
listAttachments,
openAttachmentStream,
} from "../services/attachments.js";
export const attachmentsRouter = Router();
const MAX_BYTES = 15 * 1024 * 1024; // 15 MB
// Clinical documents only — block scripts/executables.
const ALLOWED_MIME = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/heic",
"text/plain",
"text/csv",
"application/dicom",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
// names never collide or escape the directory. Runs after requireOrg, so
// req.organizationId is set.
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
ensureUploadDir(req.organizationId!)
.then((dir) => cb(null, dir))
.catch((err) => cb(err as Error, ""));
},
filename: (_req, file, cb) => {
cb(null, `${nanoid()}${path.extname(file.originalname).toLowerCase()}`);
},
});
const upload = multer({
storage,
limits: { fileSize: MAX_BYTES, files: 1 },
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
else cb(new HttpError(400, `Unsupported file type: ${file.mimetype}`));
},
});
// Wrap multer so its errors (size/type) surface as clean 400s.
function uploadSingle(req: never, res: never, next: (err?: unknown) => void) {
upload.single("file")(req, res, (err: unknown) => {
if (!err) return next();
if (err instanceof multer.MulterError) {
next(
new HttpError(
400,
err.code === "LIMIT_FILE_SIZE"
? "File is too large (max 15 MB)."
: err.message,
),
);
return;
}
next(err);
});
}
const linkSchema = z.object({
fileNumber: z.string().trim().min(1),
labKey: z.string().trim().min(1).optional(),
});
// POST /api/attachments — upload one file linked to a patient (and optionally a
// specific lab result). Allowed for clinicians (patient:write) or lab staff
// (lab:write).
attachmentsRouter.post(
"/",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
uploadSingle as never,
async (req, res, next) => {
try {
const file = req.file;
if (!file) throw new HttpError(400, "No file uploaded.");
const parsed = linkSchema.safeParse(req.body);
if (!parsed.success) throw new HttpError(400, "A fileNumber is required.");
const orgId = req.organizationId!;
const attachment = await createAttachment({
organizationId: orgId,
fileNumber: parsed.data.fileNumber,
labKey: parsed.data.labKey ?? null,
filename: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
storagePath: path.join(orgId, file.filename),
uploadedByUserId: req.user?.id ?? null,
});
await recordActivity({
orgId,
actor: { id: req.user?.id, name: req.user?.name },
action: "attachment.upload",
entityType: "patient",
entityId: attachment.id,
patientFileNumber: parsed.data.fileNumber,
}).catch(() => {});
res.status(201).json(attachment);
} catch (err) {
next(err);
}
},
);
// GET /api/attachments?fileNumber=… — list a patient's files.
attachmentsRouter.get(
"/",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["read"] }, { lab: ["read"] }),
async (req, res, next) => {
try {
const fileNumber = String(req.query.fileNumber ?? "").trim();
if (!fileNumber) throw new HttpError(400, "A fileNumber is required.");
res.json(await listAttachments(req.organizationId!, fileNumber));
} catch (err) {
next(err);
}
},
);
// GET /api/attachments/:id — stream/download a file. Images and PDFs are sent
// inline so the client can preview them in a dialog.
attachmentsRouter.get(
"/:id",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["read"] }, { lab: ["read"] }),
async (req, res, next) => {
try {
const row = await getAttachmentRow(
req.organizationId!,
String(req.params.id),
);
if (!row) throw new HttpError(404, "File not found.");
const inline =
row.mimeType.startsWith("image/") || row.mimeType === "application/pdf";
res.setHeader("Content-Type", row.mimeType);
res.setHeader(
"Content-Disposition",
`${inline ? "inline" : "attachment"}; filename="${encodeURIComponent(
row.filename,
)}"`,
);
const stream = openAttachmentStream(row.storagePath);
stream.on("error", () => next(new HttpError(404, "File not found.")));
stream.pipe(res);
} catch (err) {
next(err);
}
},
);
// DELETE /api/attachments/:id — remove a file (row + bytes).
attachmentsRouter.delete(
"/:id",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
async (req, res, next) => {
try {
const row = await getAttachmentRow(
req.organizationId!,
String(req.params.id),
);
if (!row) throw new HttpError(404, "File not found.");
await deleteAttachment(req.organizationId!, row);
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+122
View File
@@ -0,0 +1,122 @@
import { createReadStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import path from "node:path";
import { and, desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { attachments } from "../db/schema/attachments.js";
import { user } from "../db/schema/auth.js";
import { env } from "../env.js";
type AttachmentRow = typeof attachments.$inferSelect;
// API shape returned to the client (no on-disk path leaked).
export type Attachment = {
id: string;
fileNumber: string | null;
labKey: string | null;
filename: string;
mimeType: string;
sizeBytes: number;
uploadedByName: string | null;
createdAt: string;
};
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Absolute path on disk for a stored file's relative `storagePath`.
export function absolutePath(storagePath: string): string {
return path.resolve(env.UPLOAD_DIR, storagePath);
}
// The directory new uploads for a clinic are written to (created on demand).
export async function ensureUploadDir(orgId: string): Promise<string> {
const dir = path.resolve(env.UPLOAD_DIR, orgId);
await mkdir(dir, { recursive: true });
return dir;
}
function toAttachment(
row: AttachmentRow,
uploadedByName: string | null,
): Attachment {
return {
id: row.id,
fileNumber: row.fileNumber,
labKey: row.labKey,
filename: row.filename,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
uploadedByName,
createdAt: row.createdAt.toISOString(),
};
}
export async function createAttachment(input: {
organizationId: string;
fileNumber: string | null;
labKey: string | null;
filename: string;
mimeType: string;
sizeBytes: number;
storagePath: string;
uploadedByUserId: string | null;
}): Promise<Attachment> {
const [row] = await db.insert(attachments).values(input).returning();
if (!row) throw new Error("Failed to create attachment.");
return toAttachment(row, null);
}
export async function listAttachments(
orgId: string,
fileNumber: string,
): Promise<Attachment[]> {
const rows = await db
.select({ a: attachments, uploaderName: user.name })
.from(attachments)
.leftJoin(user, eq(attachments.uploadedByUserId, user.id))
.where(
and(
eq(attachments.organizationId, orgId),
eq(attachments.fileNumber, fileNumber),
),
)
.orderBy(desc(attachments.createdAt));
return rows.map((r) => toAttachment(r.a, r.uploaderName));
}
// The raw row (incl. storagePath), scoped to the clinic — for download/delete.
export async function getAttachmentRow(
orgId: string,
id: string,
): Promise<AttachmentRow | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.select()
.from(attachments)
.where(and(eq(attachments.organizationId, orgId), eq(attachments.id, id)))
.limit(1);
return row ?? null;
}
// Stream a stored file's bytes from disk.
export function openAttachmentStream(storagePath: string) {
return createReadStream(absolutePath(storagePath));
}
// Remove the DB row and best-effort delete the file from disk.
export async function deleteAttachment(
orgId: string,
row: AttachmentRow,
): Promise<void> {
await db
.delete(attachments)
.where(
and(eq(attachments.organizationId, orgId), eq(attachments.id, row.id)),
);
await unlink(absolutePath(row.storagePath)).catch(() => {
/* file already gone — ignore */
});
}