mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
b1abb29108
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>
71 lines
2.0 KiB
TypeScript
71 lines
2.0 KiB
TypeScript
// 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`;
|
|
}
|