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
+70
View File
@@ -0,0 +1,70 @@
// Client for the backend attachments API (patient/lab file uploads). Uploads
// use multipart/form-data so they bypass apiFetch (which forces a JSON body).
import { API_BASE_URL, ApiError, apiFetch } from "@/lib/api-client";
export type Attachment = {
id: string;
fileNumber: string | null;
labKey: string | null;
filename: string;
mimeType: string;
sizeBytes: number;
uploadedByName: string | null;
createdAt: string;
};
export function listAttachments(fileNumber: string): Promise<Attachment[]> {
return apiFetch<Attachment[]>(
`/api/attachments?fileNumber=${encodeURIComponent(fileNumber)}`,
);
}
export async function uploadAttachment(opts: {
file: File;
fileNumber: string;
labKey?: string;
}): Promise<Attachment> {
const form = new FormData();
form.append("file", opts.file);
form.append("fileNumber", opts.fileNumber);
if (opts.labKey) form.append("labKey", opts.labKey);
const res = await fetch(`${API_BASE_URL}/api/attachments`, {
method: "POST",
credentials: "include",
body: form,
});
if (res.status === 401) {
if (typeof window !== "undefined") window.location.href = "/login";
throw new ApiError(401, "Not authenticated.");
}
const body = (await res.json().catch(() => null)) as
| (Attachment & { error?: string })
| null;
if (!res.ok) {
throw new ApiError(
res.status,
body?.error ?? `Upload failed with status ${res.status}.`,
);
}
return body as Attachment;
}
export function deleteAttachment(id: string): Promise<void> {
return apiFetch<void>(`/api/attachments/${id}`, { method: "DELETE" });
}
// Direct URL to stream/preview a stored file (auth via the session cookie).
export function attachmentUrl(id: string): string {
return `${API_BASE_URL}/api/attachments/${id}`;
}
// "1.2 MB" / "734 KB" — compact size for display.
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -591,6 +591,7 @@
"advancedHint": "Record a custom analysis with a reference range.",
"refRange": "Reference range",
"refRangePlaceholder": "e.g. 13.017.0 g/dL",
"files": "Analysis files",
"cancel": "Cancel",
"submit": "Add result",
"needPatientTitle": "Pick a patient",
@@ -1088,6 +1089,20 @@
"dismiss": "Dismiss"
}
},
"patientFiles": {
"title": "Files",
"add": "Add files",
"empty": "No files uploaded.",
"remove": "Remove",
"open": "Open",
"close": "Close",
"noPreview": "No preview available for this file type.",
"deletedTitle": "File removed",
"deleteFailedTitle": "Couldn't remove file",
"deleteFailedBody": "Something went wrong, or you don't have permission. Please try again.",
"uploadFailedTitle": "Some files didn't upload",
"uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record."
},
"patientCard": {
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",