mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 12:58:08 +00:00
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:
@@ -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.0–17.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",
|
||||
|
||||
Reference in New Issue
Block a user