Merge backend history into monorepo

This commit is contained in:
Khalid Abdi
2026-06-07 17:59:18 +03:00
39 changed files with 10370 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.env.local
*.log
.DS_Store
Dockerfile
docker-compose.yml
README.md
+30
View File
@@ -0,0 +1,30 @@
# --- Database -------------------------------------------------------------
# When running via docker compose, "db" is the Postgres service hostname.
# For running the backend directly on your host, use localhost:5432.
DATABASE_URL=postgres://temetro:temetro@db:5432/temetro
# --- Better Auth ----------------------------------------------------------
# Generate a strong secret (min 32 chars): openssl rand -base64 32
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
# --- App ------------------------------------------------------------------
# The frontend origin — used for CORS, Better Auth trustedOrigins, and the
# links embedded in verification / reset / invitation emails.
FRONTEND_URL=http://localhost:3000
PORT=4000
NODE_ENV=development
# Host port Postgres is published on by docker compose. Change it if 5432 is
# already in use on your machine (the app still talks to Postgres internally).
POSTGRES_PORT=5432
# --- Email (optional) -----------------------------------------------------
# If SMTP_HOST is unset, emails (verify/reset/invite) are printed to the
# server console instead of being sent — zero setup for local development.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM="temetro <no-reply@temetro.local>"
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.env
.env.local
*.log
npm-debug.log*
.DS_Store
coverage
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
Guidance for Claude Code when working in `backend/`. See the root `../CLAUDE.md` for the project
vision and `README.md` here for run/setup instructions.
## What this is
The temetro API: **TypeScript + Express 5 + Postgres (Drizzle ORM)**, with authentication and
multi-tenant clinics via **Better Auth**. Serves the `../frontend` app. ESM (`"type": "module"`),
Node ≥ 20. This is its own git repo — commit changes here with the
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer.
## Commands
```bash
npm run dev # tsx watch on http://localhost:4000
npm run build # tsc -> dist/
npm run typecheck # tsc --noEmit
npm run auth:generate # Better Auth CLI -> src/db/schema/auth.ts (re-run after auth config changes)
npm run db:generate # drizzle-kit: schema -> ./drizzle/*.sql migration
npm run db:migrate # drizzle-kit: apply migrations (local dev)
npm run db:apply # node dist/migrate.js — runtime migrator (used by Docker)
```
No test runner is configured. Verify by running the stack (`docker compose up`) and curling, or
`npm run dev` against a local Postgres. See `README.md`.
## Architecture
- **`src/auth.ts`** — the Better Auth config (the CLI auto-discovers it). Email/password,
organization plugin (clinics) with custom RBAC from **`src/lib/access.ts`**
(`owner`/`admin`/`member`/`viewer` + a `patient` resource). Mounted in `src/index.ts` via
`toNodeHandler(auth)` at `/api/auth/*`.
- **`src/db/`** — `index.ts` is the Drizzle client (no schema passed; we use the core query builder).
`schema/auth.ts` is **generated** by the Better Auth CLI; `schema/patients.ts` is hand-written and
references the generated `organization`/`user` tables.
- **`src/services/patients.ts`** maps DB rows ⇆ the canonical `Patient` shape
(`src/types/patient.ts`, mirrors `../frontend/lib/patients.ts`). **`src/routes/patients.ts`** is
org-scoped CRUD, gated by **`src/middleware/auth.ts`** (`requireAuth``requireOrg`
`requirePermission`).
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
## Gotchas / conventions
- **Schema generation order (circular bootstrap):** `auth.ts``db/index.ts` must NOT pull in the
patient schema, so the Better Auth CLI can load `auth.ts` to (re)generate `schema/auth.ts`. After
any auth/plugin change: `npm run auth:generate``npm run db:generate``npm run db:migrate`.
- **Express 5** needs a named wildcard: `app.all("/api/auth/*splat", …)`, and the Better Auth handler
must be registered **before** `express.json()`.
- **Secure cookies** key off `BETTER_AUTH_URL` scheme (https), **not** `NODE_ENV` — otherwise login
breaks over `http://localhost` in the production-mode Docker stack.
- **Rate limiting** needs a client IP; behind no proxy we backfill `x-forwarded-for` from the socket
in `index.ts` so it still applies.
- **Env:** `src/env.ts` (zod) treats empty strings as unset (compose passes `${VAR:-}` as `""`) and
has dev defaults so the CLIs can load the config offline.
- **Email verification is wired but NOT enforced** at sign-in
(`emailAndPassword.requireEmailVerification: false` in `auth.ts`) — re-enable by flipping it to
`true` (and route signup back to `/verify-email` in the frontend).
- Docker: migrations run on container start (`node dist/migrate.js`); `docker-compose.yml` exposes a
configurable `POSTGRES_PORT` host port.
## Not built yet
The AI `/chat` endpoint (LLM proxy) and the signing / patient-owned-storage / approval flow.
+19
View File
@@ -0,0 +1,19 @@
# --- build stage: compile TypeScript -> dist ------------------------------
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- runtime stage: prod deps only -----------------------------------------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY --from=build /app/drizzle ./drizzle
EXPOSE 4000
# Apply migrations, then start the API.
CMD ["sh", "-c", "node dist/migrate.js && node dist/index.js"]
+75
View File
@@ -0,0 +1,75 @@
# temetro backend
TypeScript + Express + Postgres API for [temetro](../). Authentication is powered by
[Better Auth](https://better-auth.com) (email/password with verification, password reset, and
multi-tenant **organizations** with role-based access control). Patient records are **scoped to an
organization** (a clinic) and exposed over a small REST API that mirrors the frontend's `Patient`
shape exactly.
## Quick start (Docker)
```bash
cp .env.example .env
# set a strong secret:
# openssl rand -base64 32 -> paste into BETTER_AUTH_SECRET
docker compose up # db + backend + frontend
```
- Frontend → http://localhost:3000
- Backend → http://localhost:4000 (health: `GET /health`, auth health: `GET /api/auth/ok`)
- Postgres → localhost:5432
Run only the API + database: `docker compose up db backend`.
Browse the DB with Adminer: `docker compose --profile tools up adminer` → http://localhost:8080.
Migrations are applied automatically on container start (`node dist/migrate.js`).
## Local development (without Docker)
```bash
npm install
cp .env.example .env # point DATABASE_URL at a local Postgres (localhost:5432)
npm run db:migrate # apply migrations (drizzle-kit)
npm run dev # tsx watch on http://localhost:4000
```
## Auth & schema workflow
The Better Auth tables are generated into `src/db/schema/auth.ts` from `src/auth.ts`. **Re-run after
changing auth config/plugins**, then regenerate the SQL migration:
```bash
npm run auth:generate # better-auth CLI -> src/db/schema/auth.ts
npm run db:generate # drizzle-kit -> ./drizzle/*.sql
npm run db:migrate # apply
```
## API
All patient routes require a signed-in user (Better Auth session cookie) **and** an active
organization; access is gated by the caller's clinic role.
| Method | Path | Permission | Notes |
| --- | --- | --- | --- |
| GET | `/api/patients` | `patient:read` | list patients in the active clinic |
| GET | `/api/patients/:fileNumber` | `patient:read` | one patient (404 if absent) |
| POST | `/api/patients` | `patient:write` | create (409 if file number exists) |
| PUT | `/api/patients/:fileNumber` | `patient:write` | replace the full record |
| DELETE | `/api/patients/:fileNumber` | `patient:delete` | remove a patient |
Auth endpoints (sign up / in / out, verify email, reset password, organizations & invitations) are
served by Better Auth under `/api/auth/*`.
### Roles
| Role | Patient access | Clinic management |
| --- | --- | --- |
| `owner` | read / write / delete | full |
| `admin` | read / write / delete | members, invitations, settings |
| `member` (clinician) | read / write | — |
| `viewer` | read | — |
## Environment
See `.env.example`. If `SMTP_HOST` is unset, verification / reset / invitation emails are **printed
to the server console** instead of being sent — no setup required for local development.
+80
View File
@@ -0,0 +1,80 @@
# Full-stack dev/run orchestration for temetro.
#
# cp .env.example .env # then set BETTER_AUTH_SECRET
# docker compose up # db + backend + frontend
#
# Frontend -> http://localhost:3000
# Backend -> http://localhost:4000
# Postgres -> localhost:5432
#
# The frontend service builds the sibling ../frontend app. Run just the API
# with: docker compose up db backend
#
# Optional DB browser (Adminer) lives behind a profile:
# docker compose --profile tools up adminer # http://localhost:8080
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: temetro
POSTGRES_PASSWORD: temetro
POSTGRES_DB: temetro
ports:
# Host port is configurable to avoid clashing with an existing Postgres.
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- temetro_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U temetro -d temetro"]
interval: 5s
timeout: 5s
retries: 10
backend:
build:
context: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://temetro:temetro@db:5432/temetro
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env (openssl rand -base64 32)}
BETTER_AUTH_URL: http://localhost:4000
FRONTEND_URL: http://localhost:3000
PORT: "4000"
NODE_ENV: production
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-temetro <no-reply@temetro.local>}
ports:
- "4000:4000"
frontend:
build:
context: ../frontend
args:
NEXT_PUBLIC_API_URL: http://localhost:4000
restart: unless-stopped
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000
ports:
- "3000:3000"
adminer:
image: adminer:5
profiles: ["tools"]
restart: unless-stopped
depends_on:
- db
ports:
- "8080:8080"
volumes:
temetro_pgdata:
+13
View File
@@ -0,0 +1,13 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgres://temetro:temetro@localhost:5432/temetro",
},
});
+183
View File
@@ -0,0 +1,183 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invitation" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"email" text NOT NULL,
"role" text,
"status" text DEFAULT 'pending' NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"inviter_id" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"role" text DEFAULT 'member' NOT NULL,
"created_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "organization" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"logo" text,
"created_at" timestamp NOT NULL,
"metadata" text,
CONSTRAINT "organization_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "rate_limit" (
"id" text PRIMARY KEY NOT NULL,
"key" text NOT NULL,
"count" integer NOT NULL,
"last_request" bigint NOT NULL,
CONSTRAINT "rate_limit_key_unique" UNIQUE("key")
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"active_organization_id" text,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patient_allergies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"patient_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"substance" text NOT NULL,
"reaction" text NOT NULL,
"severity" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patient_encounters" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"patient_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"date" text NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"summary" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patient_labs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"patient_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"name" text NOT NULL,
"value" text NOT NULL,
"flag" text NOT NULL,
"taken_at" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patient_medications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"patient_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"name" text NOT NULL,
"dose" text NOT NULL,
"frequency" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patients" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"file_number" text NOT NULL,
"name" text NOT NULL,
"age" integer NOT NULL,
"sex" text NOT NULL,
"pcp" text NOT NULL,
"status" text NOT NULL,
"initials" text NOT NULL,
"alerts" jsonb NOT NULL,
"vitals_bp" text NOT NULL,
"vitals_hr" text NOT NULL,
"vitals_temp" text NOT NULL,
"vitals_spo2" text NOT NULL,
"vitals_taken_at" text NOT NULL,
"vitals_trend" jsonb NOT NULL,
"lab_trend" jsonb NOT NULL,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "patient_problems" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"patient_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"label" text NOT NULL,
"since" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patient_allergies" ADD CONSTRAINT "patient_allergies_patient_id_patients_id_fk" FOREIGN KEY ("patient_id") REFERENCES "public"."patients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patient_encounters" ADD CONSTRAINT "patient_encounters_patient_id_patients_id_fk" FOREIGN KEY ("patient_id") REFERENCES "public"."patients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patient_labs" ADD CONSTRAINT "patient_labs_patient_id_patients_id_fk" FOREIGN KEY ("patient_id") REFERENCES "public"."patients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patient_medications" ADD CONSTRAINT "patient_medications_patient_id_patients_id_fk" FOREIGN KEY ("patient_id") REFERENCES "public"."patients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patients" ADD CONSTRAINT "patients_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patients" ADD CONSTRAINT "patients_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "patient_problems" ADD CONSTRAINT "patient_problems_patient_id_patients_id_fk" FOREIGN KEY ("patient_id") REFERENCES "public"."patients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "invitation_organizationId_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint
CREATE INDEX "member_organizationId_idx" ON "member" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "member_userId_idx" ON "member" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "organization_slug_uidx" ON "organization" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
CREATE INDEX "allergies_patient_idx" ON "patient_allergies" USING btree ("patient_id");--> statement-breakpoint
CREATE INDEX "encounters_patient_idx" ON "patient_encounters" USING btree ("patient_id");--> statement-breakpoint
CREATE INDEX "labs_patient_idx" ON "patient_labs" USING btree ("patient_id");--> statement-breakpoint
CREATE INDEX "medications_patient_idx" ON "patient_medications" USING btree ("patient_id");--> statement-breakpoint
CREATE UNIQUE INDEX "patients_org_file_uidx" ON "patients" USING btree ("organization_id","file_number");--> statement-breakpoint
CREATE INDEX "patients_org_idx" ON "patients" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "problems_patient_idx" ON "patient_problems" USING btree ("patient_id");
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "notes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"author_id" text NOT NULL,
"title" text NOT NULL,
"content" text DEFAULT '' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notes" ADD CONSTRAINT "notes_author_id_user_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notes_org_author_idx" ON "notes" USING btree ("organization_id","author_id");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780413912874,
"tag": "0000_small_sir_ram",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780591375930,
"tag": "0001_boring_puck",
"breakpoints": true
}
]
}
+5348
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "temetro-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
"license": "MIT",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"auth:generate": "better-auth generate --config src/auth.ts --output src/db/schema/auth.ts -y",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:apply": "node dist/migrate.js",
"db:push": "drizzle-kit push"
},
"dependencies": {
"better-auth": "^1.6.13",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"nanoid": "^5.1.11",
"nodemailer": "^8.0.10",
"pg": "^8.21.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@better-auth/cli": "^1.4.21",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.9.1",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10",
"tsx": "^4.22.4",
"typescript": "^6.0.3"
}
}
+132
View File
@@ -0,0 +1,132 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { eq } from "drizzle-orm";
import { db } from "./db/index.js";
import * as authSchema from "./db/schema/auth.js";
import { env } from "./env.js";
import { ac, roles } from "./lib/access.js";
import { sendEmail } from "./lib/email.js";
const WEEK = 60 * 60 * 24 * 7;
const DAY = 60 * 60 * 24;
export const auth = betterAuth({
appName: "temetro",
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
trustedOrigins: [env.FRONTEND_URL],
database: drizzleAdapter(db, {
provider: "pg",
schema: authSchema,
}),
emailAndPassword: {
enabled: true,
// TODO(verification): Email verification is intentionally NOT enforced for
// now so users can sign in immediately. Verification emails are still sent
// (sendOnSignUp below) and the /verify-email flow still works — flip this
// back to `true` to make verification mandatory before sign-in.
requireEmailVerification: false,
minPasswordLength: 12,
maxPasswordLength: 256,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your temetro password",
text: `Reset your password by opening this link:\n\n${url}\n\nIf you didn't request this, you can ignore this email.`,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email for temetro",
text: `Welcome to temetro. Verify your email address by opening this link:\n\n${url}`,
});
},
},
plugins: [
organization({
ac,
roles,
creatorRole: "owner",
// Verification isn't enforced right now (see emailAndPassword above), so
// any signed-in user may create a clinic. Re-tie this to
// `user.emailVerified === true` when verification is re-enabled.
allowUserToCreateOrganization: true,
membershipLimit: 200,
invitationExpiresIn: WEEK,
sendInvitationEmail: async (data) => {
const url = `${env.FRONTEND_URL}/accept-invite?id=${data.invitation.id}`;
await sendEmail({
to: data.email,
subject: `${data.inviter.user.name} invited you to join ${data.organization.name} on temetro`,
text: `${data.inviter.user.name} invited you to join the clinic "${data.organization.name}".\n\nAccept the invitation:\n\n${url}`,
});
},
}),
],
rateLimit: {
enabled: true,
storage: "database",
customRules: {
"/sign-in/email": { window: 60, max: 5 },
"/sign-up/email": { window: 60, max: 3 },
"/request-password-reset": { window: 60, max: 3 },
},
},
session: {
expiresIn: WEEK,
updateAge: DAY,
cookieCache: { enabled: true, maxAge: 300 },
},
advanced: {
// Secure cookies only when actually served over HTTPS — otherwise the
// browser silently drops them over http://localhost and login "does
// nothing". This is correct regardless of NODE_ENV.
useSecureCookies: env.BETTER_AUTH_URL.startsWith("https://"),
defaultCookieAttributes: { sameSite: "lax" },
ipAddress: { ipAddressHeaders: ["x-forwarded-for", "x-real-ip"] },
},
databaseHooks: {
session: {
create: {
before: async (session) => {
// Default the active organization to the user's first clinic so
// returning members skip onboarding on sign-in.
const rows = await db
.select({ organizationId: authSchema.member.organizationId })
.from(authSchema.member)
.where(eq(authSchema.member.userId, session.userId))
.limit(1);
const organizationId = rows[0]?.organizationId;
if (organizationId) {
return { data: { ...session, activeOrganizationId: organizationId } };
}
return undefined;
},
after: async (session) => {
// Lightweight audit trail; swap console for a real sink in prod.
console.info(
`[audit] session.created user=${session.userId} ip=${session.ipAddress ?? "-"}`,
);
},
},
},
},
});
export type Auth = typeof auth;
+14
View File
@@ -0,0 +1,14 @@
import { drizzle } from "drizzle-orm/node-postgres";
import pg from "pg";
import { env } from "../env.js";
const { Pool } = pg;
export const pool = new Pool({ connectionString: env.DATABASE_URL });
// No `schema` is passed here on purpose: we use the core query builder
// (db.select/insert/update from explicit table objects), and keeping the
// patient schema out of this module avoids a circular import with auth.ts
// (which Better Auth's CLI loads to generate the auth schema).
export const db = drizzle(pool);
+193
View File
@@ -0,0 +1,193 @@
import { relations } from "drizzle-orm";
import {
pgTable,
text,
bigint,
timestamp,
boolean,
integer,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
export const rateLimit = pgTable("rate_limit", {
id: text("id").primaryKey(),
key: text("key").notNull().unique(),
count: integer("count").notNull(),
lastRequest: bigint("last_request", { mode: "number" }).notNull(),
});
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
members: many(member),
invitations: many(invitation),
}));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));
+3
View File
@@ -0,0 +1,3 @@
export * from "./auth.js";
export * from "./patients.js";
export * from "./notes.js";
+27
View File
@@ -0,0 +1,27 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// A doctor's freeform note, scoped to a clinic (organization) AND its author —
// notes are private to the doctor who wrote them within that clinic. `content`
// holds the rich-text editor's HTML.
export const notes = pgTable(
"notes",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
authorId: text("author_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content").notNull().default(""),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [index("notes_org_author_idx").on(t.organizationId, t.authorId)],
);
+144
View File
@@ -0,0 +1,144 @@
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type {
AllergySeverity,
LabFlag,
PatientStatus,
Sex,
Trend,
} from "../../types/patient.js";
import { organization, user } from "./auth.js";
// One row per patient, scoped to a clinic (organization). `fileNumber` (MRN)
// is the chat lookup key and is unique within an organization. Current vitals
// live as columns; the two headline sparkline series and the freeform alert
// list are stored as JSONB. Child collections are separate tables below.
export const patients = pgTable(
"patients",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
name: text("name").notNull(),
age: integer("age").notNull(),
sex: text("sex").$type<Sex>().notNull(),
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
vitalsTemp: text("vitals_temp").notNull(),
vitalsSpo2: text("vitals_spo2").notNull(),
vitalsTakenAt: text("vitals_taken_at").notNull(),
vitalsTrend: jsonb("vitals_trend").$type<Trend>().notNull(),
labTrend: jsonb("lab_trend").$type<Trend>().notNull(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [
uniqueIndex("patients_org_file_uidx").on(t.organizationId, t.fileNumber),
index("patients_org_idx").on(t.organizationId),
],
);
export const allergies = pgTable(
"patient_allergies",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
substance: text("substance").notNull(),
reaction: text("reaction").notNull(),
severity: text("severity").$type<AllergySeverity>().notNull(),
},
(t) => [index("allergies_patient_idx").on(t.patientId)],
);
export const medications = pgTable(
"patient_medications",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
name: text("name").notNull(),
dose: text("dose").notNull(),
frequency: text("frequency").notNull(),
},
(t) => [index("medications_patient_idx").on(t.patientId)],
);
export const problems = pgTable(
"patient_problems",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
label: text("label").notNull(),
since: text("since").notNull(),
},
(t) => [index("problems_patient_idx").on(t.patientId)],
);
export const labs = pgTable(
"patient_labs",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
name: text("name").notNull(),
value: text("value").notNull(),
flag: text("flag").$type<LabFlag>().notNull(),
takenAt: text("taken_at").notNull(),
},
(t) => [index("labs_patient_idx").on(t.patientId)],
);
export const encounters = pgTable(
"patient_encounters",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
date: text("date").notNull(),
type: text("type").notNull(),
provider: text("provider").notNull(),
summary: text("summary").notNull(),
},
(t) => [index("encounters_patient_idx").on(t.patientId)],
);
export const patientChildTables = {
allergies,
medications,
problems,
labs,
encounters,
} as const;
+55
View File
@@ -0,0 +1,55 @@
import "dotenv/config";
import { z } from "zod";
// Defaults keep this module loadable even with no .env present (e.g. when the
// Better Auth / drizzle-kit CLIs introspect the config offline). Real values
// come from .env at runtime; production misconfiguration is caught below.
const schema = z.object({
DATABASE_URL: z
.string()
.min(1)
.default("postgres://temetro:temetro@localhost:5432/temetro"),
BETTER_AUTH_SECRET: z.string().min(1).default("dev-insecure-secret-change-me"),
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),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default("temetro <no-reply@temetro.local>"),
});
// docker compose passes unset optionals as empty strings (e.g. `${SMTP_PORT:-}`).
// Treat empty strings as "unset" so optionals/defaults apply instead of failing
// coercion (e.g. Number("") === 0).
const rawEnv = Object.fromEntries(
Object.entries(process.env).map(([k, v]) => [k, v === "" ? undefined : v]),
);
const parsed = schema.safeParse(rawEnv);
if (!parsed.success) {
const lines = parsed.error.issues
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
.join("\n");
console.error(`❌ Invalid environment variables:\n${lines}`);
process.exit(1);
}
export const env = parsed.data;
// Fail fast on dangerous production misconfiguration.
if (env.NODE_ENV === "production") {
if (env.BETTER_AUTH_SECRET === "dev-insecure-secret-change-me") {
console.error(
"❌ BETTER_AUTH_SECRET is unset in production. Generate one: openssl rand -base64 32",
);
process.exit(1);
}
}
export const isProd = env.NODE_ENV === "production";
+57
View File
@@ -0,0 +1,57 @@
import { toNodeHandler } from "better-auth/node";
import cors from "cors";
import express from "express";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { errorHandler, notFound } from "./middleware/error.js";
import { notesRouter } from "./routes/notes.js";
import { patientsRouter } from "./routes/patients.js";
const app = express();
// Behind docker / a reverse proxy we trust forwarding headers for client IPs.
app.set("trust proxy", true);
app.use(
cors({
origin: env.FRONTEND_URL,
credentials: true,
}),
);
// Better Auth derives the client IP from forwarding headers (used for rate
// limiting and audit). Behind a real proxy that header is already present; for
// direct connections (local dev) we backfill it from the socket so rate
// limiting still applies.
app.use((req, _res, next) => {
if (!req.headers["x-forwarded-for"]) {
const ip = req.socket?.remoteAddress;
if (ip) req.headers["x-forwarded-for"] = ip;
}
next();
});
// Better Auth mounts its own handler. It MUST be registered before
// express.json() so it can read the raw request body. Express 5 requires a
// named wildcard ("*splat") rather than a bare "*".
app.all("/api/auth/*splat", toNodeHandler(auth));
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.use("/api/patients", patientsRouter);
app.use("/api/notes", notesRouter);
app.use(notFound);
app.use(errorHandler);
app.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(` • notes: /api/notes`);
});
+46
View File
@@ -0,0 +1,46 @@
import { createAccessControl } from "better-auth/plugins/access";
import {
adminAc,
defaultStatements,
memberAc,
ownerAc,
} from "better-auth/plugins/organization/access";
// RBAC for clinics (organizations). We extend Better Auth's default
// organization statements (organization / member / invitation / team) with a
// `patient` resource so roles can be granted fine-grained access to records.
export const statements = {
...defaultStatements,
patient: ["read", "write", "delete"],
} as const;
export const ac = createAccessControl(statements);
// We keep Better Auth's default organization role names (owner / admin /
// member) so the creator role and default membership flows work unchanged,
// and add a read-only `viewer`. In the UI these read as Owner / Admin /
// Clinician (member) / Viewer.
//
// owner / admin: run the clinic AND have full access to patient records.
export const owner = ac.newRole({
...ownerAc.statements,
patient: ["read", "write", "delete"],
});
export const admin = ac.newRole({
...adminAc.statements,
patient: ["read", "write", "delete"],
});
// member (clinician): a regular member who can read and edit patient records.
export const member = ac.newRole({
...memberAc.statements,
patient: ["read", "write"],
});
// viewer: read-only access to patient records.
export const viewer = ac.newRole({
patient: ["read"],
});
export const roles = { owner, admin, member, viewer };
+49
View File
@@ -0,0 +1,49 @@
import nodemailer from "nodemailer";
import { env } from "../env.js";
type SendArgs = {
to: string;
subject: string;
text: string;
html?: string;
};
// Lazily build a transport. With SMTP_HOST configured we send real mail;
// otherwise we fall back to logging the message (and any links) to the
// server console — zero setup for local / open-source development.
const transport = env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
secure: (env.SMTP_PORT ?? 587) === 465,
auth:
env.SMTP_USER && env.SMTP_PASS
? { user: env.SMTP_USER, pass: env.SMTP_PASS }
: undefined,
})
: null;
export async function sendEmail({ to, subject, text, html }: SendArgs): Promise<void> {
if (!transport) {
console.info(
[
"",
"✉️ [email:console] No SMTP configured — printing instead of sending.",
` to: ${to}`,
` subject: ${subject}`,
` body: ${text}`,
"",
].join("\n"),
);
return;
}
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject,
text,
html: html ?? text,
});
}
+9
View File
@@ -0,0 +1,9 @@
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
// Payload accepted by POST/PUT /api/notes. `content` is editor HTML (capped to
// avoid unbounded payloads).
export const noteInputSchema = z.object({
title: z.string().trim().min(1, "Title is required.").max(200),
content: z.string().max(100_000).default(""),
});
export type NoteInput = z.infer<typeof noteInputSchema>;
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod";
const nonEmpty = z.string().trim().min(1);
export const allergySchema = z.object({
substance: nonEmpty,
reaction: nonEmpty,
severity: z.enum(["mild", "moderate", "severe"]),
});
export const medicationSchema = z.object({
name: nonEmpty,
dose: nonEmpty,
frequency: nonEmpty,
});
export const problemSchema = z.object({
label: nonEmpty,
since: nonEmpty,
});
export const labSchema = z.object({
name: nonEmpty,
value: nonEmpty,
flag: z.enum(["normal", "high", "low", "critical"]),
takenAt: nonEmpty,
});
export const encounterSchema = z.object({
date: nonEmpty,
type: nonEmpty,
provider: nonEmpty,
summary: nonEmpty,
});
export const vitalsSchema = z.object({
bp: z.string(),
hr: z.string(),
temp: z.string(),
spo2: z.string(),
takenAt: z.string(),
});
export const trendSchema = z.object({
label: z.string(),
unit: z.string(),
points: z.array(z.number()),
});
// A full patient payload — the frontend form sends the entire record on both
// create and edit, so the same schema covers both.
export const patientInputSchema = z.object({
fileNumber: z.string().trim().regex(/^\d+$/, "File number must be digits"),
name: nonEmpty,
age: z.number().int().min(0).max(150),
sex: z.enum(["M", "F"]),
pcp: z.string(),
status: z.enum(["active", "inpatient", "discharged"]),
initials: z.string().trim().min(1).max(4),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
problems: z.array(problemSchema).default([]),
vitals: vitalsSchema,
vitalsTrend: trendSchema,
labs: z.array(labSchema).default([]),
labTrend: trendSchema,
encounters: z.array(encounterSchema).default([]),
});
export type PatientInput = z.infer<typeof patientInputSchema>;
+98
View File
@@ -0,0 +1,98 @@
import { fromNodeHeaders } from "better-auth/node";
import { and, eq } from "drizzle-orm";
import type { NextFunction, Request, Response } from "express";
import { auth } from "../auth.js";
import { db } from "../db/index.js";
import { member } from "../db/schema/auth.js";
import { roles } from "../lib/access.js";
import { HttpError } from "../lib/http-error.js";
// Validates the Better Auth session cookie and attaches the user + session.
export async function requireAuth(
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> {
try {
const data = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!data?.session) {
throw new HttpError(401, "Authentication required.");
}
req.user = data.user;
req.session = data.session;
next();
} catch (err) {
next(err);
}
}
// Requires an active organization (clinic) and loads the caller's role in it.
// Must run after requireAuth.
export async function requireOrg(
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> {
try {
const orgId = req.session?.activeOrganizationId;
if (!orgId) {
throw new HttpError(
403,
"No active clinic selected. Create or select a clinic first.",
);
}
const [m] = await db
.select({ role: member.role })
.from(member)
.where(
and(eq(member.organizationId, orgId), eq(member.userId, req.user!.id)),
);
if (!m) {
throw new HttpError(403, "You are not a member of the active clinic.");
}
req.organizationId = orgId;
req.memberRole = m.role;
next();
} catch (err) {
next(err);
}
}
type PatientAction = "read" | "write" | "delete";
type PermissionRequest = { patient?: PatientAction[] };
// Gates a route on a clinic permission, evaluated against the caller's role(s)
// using the shared access-control definitions. Must run after requireOrg.
export function requirePermission(permission: 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;
for (const name of names) {
const role = roles[name as keyof typeof roles];
if (role && (await role.authorize(permission)).success) {
allowed = true;
break;
}
}
if (!allowed) {
throw new HttpError(403, "You don't have permission to do that.");
}
next();
} catch (err) {
next(err);
}
};
}
+27
View File
@@ -0,0 +1,27 @@
import type { NextFunction, Request, Response } from "express";
import { ZodError } from "zod";
import { HttpError } from "../lib/http-error.js";
export function notFound(_req: Request, res: Response): void {
res.status(404).json({ error: "Not found" });
}
// Express error handler (must take 4 args).
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof HttpError) {
res.status(err.status).json({ error: err.message });
return;
}
if (err instanceof ZodError) {
res.status(400).json({ error: "Validation failed", details: err.issues });
return;
}
console.error("[error]", err);
res.status(500).json({ error: "Internal server error" });
}
+18
View File
@@ -0,0 +1,18 @@
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db, pool } from "./db/index.js";
// Applies the SQL migrations in ./drizzle at container/app startup. Uses the
// drizzle-orm runtime migrator (a production dependency) so no dev tooling is
// needed in the runtime image.
async function main(): Promise<void> {
console.log("Applying database migrations…");
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations up to date.");
await pool.end();
}
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
+78
View File
@@ -0,0 +1,78 @@
import { Router } from "express";
import { HttpError } from "../lib/http-error.js";
import { noteInputSchema } from "../lib/note-validation.js";
import { requireAuth, requireOrg } from "../middleware/auth.js";
import * as service from "../services/notes.js";
export const notesRouter = Router();
// Notes are scoped to the active clinic AND the signed-in author. Any clinic
// member may manage their own notes, so no extra RBAC permission is required.
notesRouter.use(requireAuth, requireOrg);
notesRouter.get("/", async (req, res, next) => {
try {
res.json(await service.listNotes(req.organizationId!, req.user!.id));
} catch (err) {
next(err);
}
});
notesRouter.post("/", async (req, res, next) => {
try {
const input = noteInputSchema.parse(req.body);
const created = await service.createNote(
req.organizationId!,
req.user!.id,
input,
);
res.status(201).json(created);
} catch (err) {
next(err);
}
});
notesRouter.get("/:id", async (req, res, next) => {
try {
const note = await service.getNote(
req.organizationId!,
req.user!.id,
req.params.id as string,
);
if (!note) throw new HttpError(404, "Note not found.");
res.json(note);
} catch (err) {
next(err);
}
});
notesRouter.put("/:id", async (req, res, next) => {
try {
const input = noteInputSchema.parse(req.body);
const updated = await service.updateNote(
req.organizationId!,
req.user!.id,
req.params.id as string,
input,
);
if (!updated) throw new HttpError(404, "Note not found.");
res.json(updated);
} catch (err) {
next(err);
}
});
notesRouter.delete("/:id", async (req, res, next) => {
try {
const ok = await service.deleteNote(
req.organizationId!,
req.user!.id,
req.params.id as string,
);
if (!ok) throw new HttpError(404, "Note not found.");
res.status(204).end();
} catch (err) {
next(err);
}
});
+98
View File
@@ -0,0 +1,98 @@
import { Router } from "express";
import { HttpError } from "../lib/http-error.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import * as service from "../services/patients.js";
export const patientsRouter = Router();
// Every patient route requires a signed-in user with an active clinic.
patientsRouter.use(requireAuth, requireOrg);
patientsRouter.get(
"/",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await service.listPatients(req.organizationId!));
} catch (err) {
next(err);
}
},
);
patientsRouter.get(
"/:fileNumber",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const patient = await service.getPatient(
req.organizationId!,
req.params.fileNumber as string,
);
if (!patient) throw new HttpError(404, "Patient not found.");
res.json(patient);
} catch (err) {
next(err);
}
},
);
patientsRouter.post(
"/",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = patientInputSchema.parse(req.body);
const created = await service.createPatient(
req.organizationId!,
req.user!.id,
input,
);
res.status(201).json(created);
} catch (err) {
next(err);
}
},
);
patientsRouter.put(
"/:fileNumber",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = patientInputSchema.parse(req.body);
const updated = await service.updatePatient(
req.organizationId!,
req.params.fileNumber as string,
input,
);
if (!updated) throw new HttpError(404, "Patient not found.");
res.json(updated);
} catch (err) {
next(err);
}
},
);
patientsRouter.delete(
"/:fileNumber",
requirePermission({ patient: ["delete"] }),
async (req, res, next) => {
try {
const ok = await service.deletePatient(
req.organizationId!,
req.params.fileNumber as string,
);
if (!ok) throw new HttpError(404, "Patient not found.");
res.status(204).end();
} catch (err) {
next(err);
}
},
);
+110
View File
@@ -0,0 +1,110 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { notes } from "../db/schema/notes.js";
import type { NoteInput } from "../lib/note-validation.js";
import type { Note } from "../types/note.js";
type NoteRow = typeof notes.$inferSelect;
// Postgres throws on a malformed uuid; treat non-uuid ids as "not found".
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function toNote(row: NoteRow): Note {
return {
id: row.id,
title: row.title,
content: row.content,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function listNotes(
orgId: string,
authorId: string,
): Promise<Note[]> {
const rows = await db
.select()
.from(notes)
.where(and(eq(notes.organizationId, orgId), eq(notes.authorId, authorId)))
.orderBy(desc(notes.updatedAt));
return rows.map(toNote);
}
export async function getNote(
orgId: string,
authorId: string,
id: string,
): Promise<Note | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.select()
.from(notes)
.where(
and(
eq(notes.id, id),
eq(notes.organizationId, orgId),
eq(notes.authorId, authorId),
),
);
return row ? toNote(row) : null;
}
export async function createNote(
orgId: string,
authorId: string,
input: NoteInput,
): Promise<Note> {
const [row] = await db
.insert(notes)
.values({
organizationId: orgId,
authorId,
title: input.title,
content: input.content,
})
.returning();
return toNote(row!);
}
export async function updateNote(
orgId: string,
authorId: string,
id: string,
input: NoteInput,
): Promise<Note | null> {
if (!UUID_RE.test(id)) return null;
const [row] = await db
.update(notes)
.set({ title: input.title, content: input.content })
.where(
and(
eq(notes.id, id),
eq(notes.organizationId, orgId),
eq(notes.authorId, authorId),
),
)
.returning();
return row ? toNote(row) : null;
}
export async function deleteNote(
orgId: string,
authorId: string,
id: string,
): Promise<boolean> {
if (!UUID_RE.test(id)) return false;
const deleted = await db
.delete(notes)
.where(
and(
eq(notes.id, id),
eq(notes.organizationId, orgId),
eq(notes.authorId, authorId),
),
)
.returning({ id: notes.id });
return deleted.length > 0;
}
+329
View File
@@ -0,0 +1,329 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { db } from "../db/index.js";
import {
allergies,
encounters,
labs,
medications,
patients,
problems,
} from "../db/schema/patients.js";
import { HttpError } from "../lib/http-error.js";
import type { PatientInput } from "../lib/patient-validation.js";
import type {
Allergy,
Encounter,
Lab,
Medication,
Patient,
Problem,
} from "../types/patient.js";
type PatientRow = typeof patients.$inferSelect;
type Children = {
allergies: Allergy[];
medications: Medication[];
problems: Problem[];
labs: Lab[];
encounters: Encounter[];
};
const emptyChildren = (): Children => ({
allergies: [],
medications: [],
problems: [],
labs: [],
encounters: [],
});
function toPatient(row: PatientRow, children: Children): Patient {
return {
fileNumber: row.fileNumber,
name: row.name,
age: row.age,
sex: row.sex,
pcp: row.pcp,
status: row.status,
initials: row.initials,
allergies: children.allergies,
alerts: row.alerts,
medications: children.medications,
problems: children.problems,
vitals: {
bp: row.vitalsBp,
hr: row.vitalsHr,
temp: row.vitalsTemp,
spo2: row.vitalsSpo2,
takenAt: row.vitalsTakenAt,
},
vitalsTrend: row.vitalsTrend,
labs: children.labs,
labTrend: row.labTrend,
encounters: children.encounters,
};
}
// Input children are already in the canonical Patient sub-shapes.
function childrenFromInput(input: PatientInput): Children {
return {
allergies: input.allergies,
medications: input.medications,
problems: input.problems,
labs: input.labs,
encounters: input.encounters,
};
}
function patientColumns(orgId: string, input: PatientInput, createdBy?: string) {
return {
organizationId: orgId,
fileNumber: input.fileNumber,
name: input.name,
age: input.age,
sex: input.sex,
pcp: input.pcp,
status: input.status,
initials: input.initials,
alerts: input.alerts,
vitalsBp: input.vitals.bp,
vitalsHr: input.vitals.hr,
vitalsTemp: input.vitals.temp,
vitalsSpo2: input.vitals.spo2,
vitalsTakenAt: input.vitals.takenAt,
vitalsTrend: input.vitalsTrend,
labTrend: input.labTrend,
...(createdBy ? { createdBy } : {}),
};
}
// Loads and groups child rows for a set of patients in one round-trip each.
async function loadChildren(
patientIds: string[],
): Promise<Map<string, Children>> {
const grouped = new Map<string, Children>();
for (const id of patientIds) grouped.set(id, emptyChildren());
if (patientIds.length === 0) return grouped;
const [al, me, pr, la, en] = await Promise.all([
db
.select()
.from(allergies)
.where(inArray(allergies.patientId, patientIds))
.orderBy(asc(allergies.position)),
db
.select()
.from(medications)
.where(inArray(medications.patientId, patientIds))
.orderBy(asc(medications.position)),
db
.select()
.from(problems)
.where(inArray(problems.patientId, patientIds))
.orderBy(asc(problems.position)),
db
.select()
.from(labs)
.where(inArray(labs.patientId, patientIds))
.orderBy(asc(labs.position)),
db
.select()
.from(encounters)
.where(inArray(encounters.patientId, patientIds))
.orderBy(asc(encounters.position)),
]);
for (const a of al)
grouped.get(a.patientId)?.allergies.push({
substance: a.substance,
reaction: a.reaction,
severity: a.severity,
});
for (const m of me)
grouped.get(m.patientId)?.medications.push({
name: m.name,
dose: m.dose,
frequency: m.frequency,
});
for (const p of pr)
grouped.get(p.patientId)?.problems.push({ label: p.label, since: p.since });
for (const l of la)
grouped.get(l.patientId)?.labs.push({
name: l.name,
value: l.value,
flag: l.flag,
takenAt: l.takenAt,
});
for (const e of en)
grouped.get(e.patientId)?.encounters.push({
date: e.date,
type: e.type,
provider: e.provider,
summary: e.summary,
});
return grouped;
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
async function insertChildren(
tx: Tx,
patientId: string,
input: PatientInput,
): Promise<void> {
if (input.allergies.length)
await tx
.insert(allergies)
.values(input.allergies.map((a, i) => ({ patientId, position: i, ...a })));
if (input.medications.length)
await tx
.insert(medications)
.values(
input.medications.map((m, i) => ({ patientId, position: i, ...m })),
);
if (input.problems.length)
await tx
.insert(problems)
.values(input.problems.map((p, i) => ({ patientId, position: i, ...p })));
if (input.labs.length)
await tx
.insert(labs)
.values(input.labs.map((l, i) => ({ patientId, position: i, ...l })));
if (input.encounters.length)
await tx
.insert(encounters)
.values(
input.encounters.map((e, i) => ({ patientId, position: i, ...e })),
);
}
function isUniqueViolation(err: unknown): boolean {
// drizzle wraps the driver error, so the pg error code (23505) may sit on
// the error itself or on its `cause`.
const candidates = [err, (err as { cause?: unknown })?.cause];
return candidates.some(
(e) =>
typeof e === "object" &&
e !== null &&
"code" in e &&
(e as { code?: string }).code === "23505",
);
}
export async function listPatients(orgId: string): Promise<Patient[]> {
const rows = await db
.select()
.from(patients)
.where(eq(patients.organizationId, orgId))
.orderBy(asc(patients.name));
const children = await loadChildren(rows.map((r) => r.id));
return rows.map((r) => toPatient(r, children.get(r.id) ?? emptyChildren()));
}
export async function getPatient(
orgId: string,
fileNumber: string,
): Promise<Patient | null> {
const [row] = await db
.select()
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
),
);
if (!row) return null;
const children = await loadChildren([row.id]);
return toPatient(row, children.get(row.id) ?? emptyChildren());
}
export async function createPatient(
orgId: string,
userId: string,
input: PatientInput,
): Promise<Patient> {
try {
return await db.transaction(async (tx) => {
const [row] = await tx
.insert(patients)
.values(patientColumns(orgId, input, userId))
.returning();
await insertChildren(tx, row!.id, input);
return toPatient(row!, childrenFromInput(input));
});
} catch (err) {
if (isUniqueViolation(err)) {
throw new HttpError(
409,
`A patient with file number ${input.fileNumber} already exists in this clinic.`,
);
}
throw err;
}
}
export async function updatePatient(
orgId: string,
fileNumber: string,
input: PatientInput,
): Promise<Patient | null> {
try {
return await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: patients.id })
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
),
);
if (!existing) return null;
const [row] = await tx
.update(patients)
.set(patientColumns(orgId, input))
.where(eq(patients.id, existing.id))
.returning();
// Replace child collections wholesale (form submits the full record).
await Promise.all([
tx.delete(allergies).where(eq(allergies.patientId, existing.id)),
tx.delete(medications).where(eq(medications.patientId, existing.id)),
tx.delete(problems).where(eq(problems.patientId, existing.id)),
tx.delete(labs).where(eq(labs.patientId, existing.id)),
tx.delete(encounters).where(eq(encounters.patientId, existing.id)),
]);
await insertChildren(tx, existing.id, input);
return toPatient(row!, childrenFromInput(input));
});
} catch (err) {
if (isUniqueViolation(err)) {
throw new HttpError(
409,
`A patient with file number ${input.fileNumber} already exists in this clinic.`,
);
}
throw err;
}
}
export async function deletePatient(
orgId: string,
fileNumber: string,
): Promise<boolean> {
const deleted = await db
.delete(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
),
)
.returning({ id: patients.id });
return deleted.length > 0;
}
+22
View File
@@ -0,0 +1,22 @@
// Request properties populated by our auth middleware.
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
name: string;
emailVerified: boolean;
};
session?: {
id: string;
userId: string;
activeOrganizationId?: string | null;
};
organizationId?: string;
memberRole?: string;
}
}
}
export {};
+9
View File
@@ -0,0 +1,9 @@
// The canonical Note shape returned by the API. Mirrors the frontend
// `lib/notes.ts` Note type. `content` is rich-text editor HTML.
export type Note = {
id: string;
title: string;
content: string;
createdAt: string;
updatedAt: string;
};
+72
View File
@@ -0,0 +1,72 @@
// Canonical patient shape — mirrors frontend/lib/patients.ts so API responses
// can be consumed by the chat record cards without any reshaping on the client.
export type AllergySeverity = "mild" | "moderate" | "severe";
export type LabFlag = "normal" | "high" | "low" | "critical";
export type Sex = "M" | "F";
export type PatientStatus = "active" | "inpatient" | "discharged";
export type Allergy = {
substance: string;
reaction: string;
severity: AllergySeverity;
};
export type Medication = {
name: string;
dose: string;
frequency: string;
};
export type Problem = {
label: string;
since: string;
};
export type Vitals = {
bp: string;
hr: string;
temp: string;
spo2: string;
takenAt: string;
};
export type Lab = {
name: string;
value: string;
flag: LabFlag;
takenAt: string;
};
export type Encounter = {
date: string;
type: string;
provider: string;
summary: string;
};
// A short series for a sparkline; `points` are most-recent-last.
export type Trend = {
label: string;
unit: string;
points: number[];
};
export type Patient = {
fileNumber: string; // MRN / file number, e.g. "10293"
name: string;
age: number;
sex: Sex;
pcp: string; // primary care provider
status: PatientStatus;
initials: string; // for AvatarFallback
allergies: Allergy[];
alerts: string[];
medications: Medication[];
problems: Problem[];
vitals: Vitals;
vitalsTrend: Trend; // headline vital plotted as a sparkline
labs: Lab[];
labTrend: Trend; // headline lab plotted as a sparkline
encounters: Encounter[];
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}