mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 02:57:55 +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>
123 lines
3.4 KiB
TypeScript
123 lines
3.4 KiB
TypeScript
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 */
|
|
});
|
|
}
|