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
+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);
}
};
}