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
+4
View File
@@ -9,6 +9,10 @@ BETTER_AUTH_SECRET=replace-me-with-openssl-rand-base64-32
# Public base URL of THIS backend (used for auth callbacks & cookies).
BETTER_AUTH_URL=http://localhost:4000
# Directory for uploaded patient/lab files. Defaults to ./uploads in local dev;
# in Docker it's a persistent volume (see docker-compose.yml).
UPLOAD_DIR=./uploads
# --- AI ------------------------------------------------------------------
# Key used to encrypt at-rest AI provider API keys (per-user, set in the app's
# Settings → AI). Generate one: openssl rand -base64 32. Rotating it forces
+1
View File
@@ -6,3 +6,4 @@ dist
npm-debug.log*
.DS_Store
coverage
uploads
+5
View File
@@ -52,6 +52,8 @@ services:
FRONTEND_URL: http://localhost:3000
PORT: "4000"
NODE_ENV: production
# Uploaded patient/lab files live here, on the temetro_uploads volume.
UPLOAD_DIR: /var/lib/temetro/uploads
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_USER: ${SMTP_USER:-}
@@ -60,6 +62,8 @@ services:
volumes:
# Persists auto-generated secrets so they stay stable across restarts.
- temetro_secrets:/var/lib/temetro
# Persists uploaded files across restarts/rebuilds.
- temetro_uploads:/var/lib/temetro/uploads
ports:
- "4000:4000"
@@ -88,3 +92,4 @@ services:
volumes:
temetro_pgdata:
temetro_secrets:
temetro_uploads:
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"file_number" text,
"lab_key" text,
"filename" text NOT NULL,
"mime_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"storage_path" text NOT NULL,
"uploaded_by_user_id" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_user_id_user_id_fk" FOREIGN KEY ("uploaded_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "attachments_org_file_idx" ON "attachments" USING btree ("organization_id","file_number");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -148,6 +148,13 @@
"when": 1781629630102,
"tag": "0020_freezing_tomas",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1781801972208,
"tag": "0021_milky_blur",
"breakpoints": true
}
]
}
+119 -14
View File
@@ -13,12 +13,14 @@
"@ai-sdk/google": "^3.0.82",
"@ai-sdk/openai": "^3.0.71",
"@ai-sdk/openai-compatible": "^2.0.50",
"@types/multer": "^2.1.0",
"ai": "^6.0.204",
"better-auth": "^1.6.13",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0",
"nanoid": "^5.1.11",
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
@@ -2246,7 +2248,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -2257,7 +2258,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2276,7 +2276,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -2288,7 +2287,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -2301,9 +2299,17 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -2339,21 +2345,18 @@
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -2363,7 +2366,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -2419,6 +2421,12 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2711,7 +2719,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/bundle-name": {
@@ -2730,6 +2737,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2879,6 +2897,21 @@
"node": ">=18"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -4027,6 +4060,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
@@ -4508,7 +4603,6 @@
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
@@ -4589,7 +4683,6 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"devOptional": true,
"funding": [
{
"type": "github",
@@ -4931,11 +5024,18 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
@@ -5537,6 +5637,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -5601,7 +5707,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/vary": {
+2
View File
@@ -24,12 +24,14 @@
"@ai-sdk/google": "^3.0.82",
"@ai-sdk/openai": "^3.0.71",
"@ai-sdk/openai-compatible": "^2.0.50",
"@types/multer": "^2.1.0",
"ai": "^6.0.204",
"better-auth": "^1.6.13",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.2.0",
"nanoid": "^5.1.11",
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
+42
View File
@@ -0,0 +1,42 @@
import {
index,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Files uploaded against a patient record (and optionally a specific lab
// result). Scoped to a clinic (organization). The bytes live on disk under
// UPLOAD_DIR (see src/services/attachments.ts); this table only holds metadata
// plus the relative `storagePath`.
// fileNumber → the patient's MRN this file belongs to.
// labKey → set when the file documents a specific lab result.
export const attachments = pgTable(
"attachments",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
fileNumber: text("file_number"),
labKey: text("lab_key"),
filename: text("filename").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
storagePath: text("storage_path").notNull(),
uploadedByUserId: text("uploaded_by_user_id").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [
index("attachments_org_file_idx").on(
table.organizationId,
table.fileNumber,
),
],
);
+1
View File
@@ -14,3 +14,4 @@ export * from "./settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
export * from "./attachments.js";
+3
View File
@@ -16,6 +16,9 @@ const schema = z.object({
.string()
.min(1)
.default("dev-insecure-ai-key-change-me"),
// Directory where uploaded patient/lab files are stored on disk. Back this
// with a persistent volume in production (see docker-compose.yml).
UPLOAD_DIR: z.string().min(1).default("./uploads"),
BETTER_AUTH_URL: z.string().min(1).default("http://localhost:4000"),
FRONTEND_URL: z.string().min(1).default("http://localhost:3000"),
PORT: z.coerce.number().int().positive().default(4000),
+3
View File
@@ -11,6 +11,7 @@ import { initRealtime } from "./realtime.js";
import { activityRouter } from "./routes/activity.js";
import { aiRouter } from "./routes/ai.js";
import { analyticsRouter } from "./routes/analytics.js";
import { attachmentsRouter } from "./routes/attachments.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { conversationsRouter } from "./routes/conversations.js";
@@ -63,6 +64,7 @@ app.get("/health", (_req, res) => {
});
app.use("/api/patients", patientsRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
app.use("/api/prescriptions", prescriptionsRouter);
@@ -90,6 +92,7 @@ server.listen(env.PORT, () => {
console.log(`temetro backend listening on ${env.BETTER_AUTH_URL}`);
console.log(` • auth: /api/auth/* (frontend origin: ${env.FRONTEND_URL})`);
console.log(` • patients: /api/patients`);
console.log(` • files: /api/attachments`);
console.log(` • notes: /api/notes`);
console.log(` • appts: /api/appointments`);
console.log(` • rx: /api/prescriptions`);
+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);
}
};
}
+202
View File
@@ -0,0 +1,202 @@
import path from "node:path";
import { Router } from "express";
import multer from "multer";
import { nanoid } from "nanoid";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import {
requireAnyPermission,
requireAuth,
requireOrg,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import {
createAttachment,
deleteAttachment,
ensureUploadDir,
getAttachmentRow,
listAttachments,
openAttachmentStream,
} from "../services/attachments.js";
export const attachmentsRouter = Router();
const MAX_BYTES = 15 * 1024 * 1024; // 15 MB
// Clinical documents only — block scripts/executables.
const ALLOWED_MIME = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/heic",
"text/plain",
"text/csv",
"application/dicom",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
]);
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
// names never collide or escape the directory. Runs after requireOrg, so
// req.organizationId is set.
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
ensureUploadDir(req.organizationId!)
.then((dir) => cb(null, dir))
.catch((err) => cb(err as Error, ""));
},
filename: (_req, file, cb) => {
cb(null, `${nanoid()}${path.extname(file.originalname).toLowerCase()}`);
},
});
const upload = multer({
storage,
limits: { fileSize: MAX_BYTES, files: 1 },
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIME.has(file.mimetype)) cb(null, true);
else cb(new HttpError(400, `Unsupported file type: ${file.mimetype}`));
},
});
// Wrap multer so its errors (size/type) surface as clean 400s.
function uploadSingle(req: never, res: never, next: (err?: unknown) => void) {
upload.single("file")(req, res, (err: unknown) => {
if (!err) return next();
if (err instanceof multer.MulterError) {
next(
new HttpError(
400,
err.code === "LIMIT_FILE_SIZE"
? "File is too large (max 15 MB)."
: err.message,
),
);
return;
}
next(err);
});
}
const linkSchema = z.object({
fileNumber: z.string().trim().min(1),
labKey: z.string().trim().min(1).optional(),
});
// POST /api/attachments — upload one file linked to a patient (and optionally a
// specific lab result). Allowed for clinicians (patient:write) or lab staff
// (lab:write).
attachmentsRouter.post(
"/",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
uploadSingle as never,
async (req, res, next) => {
try {
const file = req.file;
if (!file) throw new HttpError(400, "No file uploaded.");
const parsed = linkSchema.safeParse(req.body);
if (!parsed.success) throw new HttpError(400, "A fileNumber is required.");
const orgId = req.organizationId!;
const attachment = await createAttachment({
organizationId: orgId,
fileNumber: parsed.data.fileNumber,
labKey: parsed.data.labKey ?? null,
filename: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
storagePath: path.join(orgId, file.filename),
uploadedByUserId: req.user?.id ?? null,
});
await recordActivity({
orgId,
actor: { id: req.user?.id, name: req.user?.name },
action: "attachment.upload",
entityType: "patient",
entityId: attachment.id,
patientFileNumber: parsed.data.fileNumber,
}).catch(() => {});
res.status(201).json(attachment);
} catch (err) {
next(err);
}
},
);
// GET /api/attachments?fileNumber=… — list a patient's files.
attachmentsRouter.get(
"/",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["read"] }, { lab: ["read"] }),
async (req, res, next) => {
try {
const fileNumber = String(req.query.fileNumber ?? "").trim();
if (!fileNumber) throw new HttpError(400, "A fileNumber is required.");
res.json(await listAttachments(req.organizationId!, fileNumber));
} catch (err) {
next(err);
}
},
);
// GET /api/attachments/:id — stream/download a file. Images and PDFs are sent
// inline so the client can preview them in a dialog.
attachmentsRouter.get(
"/:id",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["read"] }, { lab: ["read"] }),
async (req, res, next) => {
try {
const row = await getAttachmentRow(
req.organizationId!,
String(req.params.id),
);
if (!row) throw new HttpError(404, "File not found.");
const inline =
row.mimeType.startsWith("image/") || row.mimeType === "application/pdf";
res.setHeader("Content-Type", row.mimeType);
res.setHeader(
"Content-Disposition",
`${inline ? "inline" : "attachment"}; filename="${encodeURIComponent(
row.filename,
)}"`,
);
const stream = openAttachmentStream(row.storagePath);
stream.on("error", () => next(new HttpError(404, "File not found.")));
stream.pipe(res);
} catch (err) {
next(err);
}
},
);
// DELETE /api/attachments/:id — remove a file (row + bytes).
attachmentsRouter.delete(
"/:id",
requireAuth,
requireOrg,
requireAnyPermission({ patient: ["write"] }, { lab: ["write"] }),
async (req, res, next) => {
try {
const row = await getAttachmentRow(
req.organizationId!,
String(req.params.id),
);
if (!row) throw new HttpError(404, "File not found.");
await deleteAttachment(req.organizationId!, row);
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+122
View File
@@ -0,0 +1,122 @@
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 */
});
}
@@ -4,6 +4,7 @@ import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { StagedFilesField } from "@/components/patients/patient-files";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
@@ -33,6 +34,7 @@ import {
type Patient,
updatePatient,
} from "@/lib/patients";
import { uploadAttachment } from "@/lib/attachments";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { listProviders, type Provider } from "@/lib/staff";
import { notify } from "@/lib/toast";
@@ -217,6 +219,9 @@ export function PatientFormDialog({
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Files staged in the form, uploaded once the patient record is saved (so the
// attachment can be linked to the file number).
const [files, setFiles] = useState<File[]>([]);
const [fileNumber, setFileNumber] = useState(() =>
isEdit && patient ? patient.fileNumber : generateFileNumber()
@@ -334,6 +339,21 @@ export function PatientFormDialog({
const saved = isEdit
? await updatePatient(built)
: await createPatient(built);
// Upload any staged files now that we have a saved file number.
if (files.length > 0) {
const results = await Promise.allSettled(
files.map((file) =>
uploadAttachment({ file, fileNumber: saved.fileNumber }),
),
);
if (results.some((r) => r.status === "rejected")) {
notify.error(
t("patientFiles.uploadFailedTitle"),
t("patientFiles.uploadFailedBody"),
);
}
setFiles([]);
}
if (isEdit) {
onSaved?.(saved);
notify.success(
@@ -669,6 +689,8 @@ export function PatientFormDialog({
/>
</>
)}
<StagedFilesField onChange={setFiles} value={files} />
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
+30
View File
@@ -39,6 +39,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { StagedFilesField } from "@/components/patients/patient-files";
import { uploadAttachment } from "@/lib/attachments";
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
import {
type Lab,
@@ -173,6 +175,8 @@ function AddResultDialog({
const [advanced, setAdvanced] = useState(false);
const [refRange, setRefRange] = useState("");
const [saving, setSaving] = useState(false);
// Analysis files (PDF/image) attached to this result.
const [files, setFiles] = useState<File[]>([]);
const reset = () => {
setPatient(null);
@@ -184,6 +188,7 @@ function AddResultDialog({
setTakenAt(today());
setAdvanced(false);
setRefRange("");
setFiles([]);
setSaving(false);
};
@@ -250,6 +255,25 @@ function AddResultDialog({
setSaving(true);
try {
await appendLabs(patient.fileNumber, [lab]);
// Attach any analysis files to this result (best-effort).
if (files.length > 0) {
const labKey = `${lab.name} · ${lab.takenAt}`;
const results = await Promise.allSettled(
files.map((file) =>
uploadAttachment({
file,
fileNumber: patient.fileNumber,
labKey,
}),
),
);
if (results.some((r) => r.status === "rejected")) {
notify.error(
t("patientFiles.uploadFailedTitle"),
t("patientFiles.uploadFailedBody"),
);
}
}
notify.success(
t("lab.addResult.addedTitle"),
t("lab.addResult.addedBody", { test: lab.name, name: patient.name }),
@@ -439,6 +463,12 @@ function AddResultDialog({
/>
</Field>
)}
<StagedFilesField
label={t("lab.addResult.files")}
onChange={setFiles}
value={files}
/>
</DialogPanel>
<DialogFooter>
@@ -5,6 +5,7 @@ import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -285,6 +286,8 @@ export function PatientDetail({
</div>
</Section>
<AttachmentsSection fileNumber={patient.fileNumber} />
<Section title={t("patientCard.vitals.title")}>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<Stat label={t("patientCard.vitals.bp")} value={patient.vitals.bp} />
@@ -0,0 +1,242 @@
"use client";
import { FileText, Paperclip, Trash2, Upload, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
type Attachment,
attachmentUrl,
deleteAttachment,
formatBytes,
listAttachments,
} from "@/lib/attachments";
import { notify } from "@/lib/toast";
// A pick-and-stage field: files chosen here are held in `value` until the
// parent uploads them (after the patient/lab record is saved). Used in the
// patient form and the lab "add result" dialog.
export function StagedFilesField({
value,
onChange,
label,
}: {
value: File[];
onChange: (files: File[]) => void;
label?: string;
}) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label ?? t("patientFiles.title")}
</span>
<Button
onClick={() => inputRef.current?.click()}
size="sm"
type="button"
variant="ghost"
>
<Upload className="size-4" />
{t("patientFiles.add")}
</Button>
<input
className="hidden"
multiple
onChange={(e) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length) onChange([...value, ...picked]);
e.target.value = "";
}}
ref={inputRef}
type="file"
/>
</div>
{value.length > 0 && (
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
{value.map((file, index) => (
<div
className="flex items-center gap-2.5 px-3 py-2"
key={`${file.name}-${index}`}
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{file.name}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatBytes(file.size)}
</span>
<button
aria-label={t("patientFiles.remove")}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => onChange(value.filter((_, i) => i !== index))}
type="button"
>
<X className="size-4" />
</button>
</div>
))}
</div>
)}
</div>
);
}
// A dialog that previews an attachment: images inline, everything else as a
// download link.
function FilePreviewDialog({
attachment,
onClose,
}: {
attachment: Attachment | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const isImage = attachment?.mimeType.startsWith("image/");
return (
<Dialog onOpenChange={(o) => !o && onClose()} open={attachment !== null}>
<DialogPopup className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="truncate">{attachment?.filename}</DialogTitle>
</DialogHeader>
<DialogPanel className="flex flex-col items-center gap-3">
{attachment && isImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={attachment.filename}
className="max-h-[60vh] w-auto rounded-lg border object-contain"
src={attachmentUrl(attachment.id)}
/>
) : (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<FileText className="size-10 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
{t("patientFiles.noPreview")}
</p>
</div>
)}
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientFiles.close")}
</DialogClose>
{attachment && (
<Button
render={
<a
href={attachmentUrl(attachment.id)}
rel="noreferrer"
target="_blank"
/>
}
>
{t("patientFiles.open")}
</Button>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
// The sheet's "Files" section: lists a patient's uploaded attachments, opens
// one in a preview dialog, and lets a clinician delete it. `reloadKey` bumps to
// refetch after a new upload elsewhere.
export function AttachmentsSection({
fileNumber,
reloadKey = 0,
canDelete = true,
}: {
fileNumber: string;
reloadKey?: number;
canDelete?: boolean;
}) {
const { t } = useTranslation();
const [items, setItems] = useState<Attachment[]>([]);
const [preview, setPreview] = useState<Attachment | null>(null);
useEffect(() => {
let active = true;
listAttachments(fileNumber)
.then((rows) => active && setItems(rows))
.catch(() => {
/* missing permission / none — leave empty */
});
return () => {
active = false;
};
}, [fileNumber, reloadKey]);
const remove = async (attachment: Attachment) => {
try {
await deleteAttachment(attachment.id);
setItems((prev) => prev.filter((a) => a.id !== attachment.id));
notify.success(t("patientFiles.deletedTitle"), attachment.filename);
} catch {
notify.error(
t("patientFiles.deleteFailedTitle"),
t("patientFiles.deleteFailedBody"),
);
}
};
return (
<section className="rounded-2xl border bg-card/30 p-4">
<h3 className="mb-3 font-medium text-foreground text-sm">
{t("patientFiles.title")}
</h3>
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("patientFiles.empty")}
</p>
) : (
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-background/40">
{items.map((attachment) => (
<div
className="flex items-center gap-2.5 px-3 py-2"
key={attachment.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
onClick={() => setPreview(attachment)}
type="button"
>
<Paperclip className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{attachment.filename}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{formatBytes(attachment.sizeBytes)}
</span>
</button>
{canDelete && (
<button
aria-label={t("patientFiles.remove")}
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive-foreground"
onClick={() => remove(attachment)}
type="button"
>
<Trash2 className="size-4" />
</button>
)}
</div>
))}
</div>
)}
<FilePreviewDialog attachment={preview} onClose={() => setPreview(null)} />
</section>
);
}
+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",