Add TypeScript + Express + Postgres backend with Better Auth

Implements the first temetro backend: an Express 5 API on Postgres via
Drizzle ORM, with authentication and multi-tenant clinics powered by
Better Auth.

- Auth: email/password with required email verification, password reset,
  rate limiting, CSRF/trusted-origins, secure cookies, session audit hook.
- Organizations (clinics) with RBAC (owner/admin/member/viewer) and an
  extended `patient` permission set; member invitations by email.
- Org-scoped patient records mirroring the frontend Patient shape, with
  CRUD endpoints gated by permission (read/write/delete).
- Email helper logs links to the console when SMTP is unset (zero-setup
  local dev); Dockerfile + docker-compose (db + backend + frontend) with
  migrations applied on startup and a configurable Postgres host port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-02 21:27:32 +03:00
parent a39ecbe600
commit 9dabe2f5d2
31 changed files with 8576 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
+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");
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780413912874,
"tag": "0000_small_sir_ram",
"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"
}
}
+107
View File
@@ -0,0 +1,107 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { db } from "./db/index.js";
import * as authSchema from "./db/schema/auth.js";
import { env, isProd } 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,
requireEmailVerification: true,
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",
allowUserToCreateOrganization: async (user) => user.emailVerified === 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: {
useSecureCookies: isProd,
defaultCookieAttributes: { sameSite: "lax" },
ipAddress: { ipAddressHeaders: ["x-forwarded-for", "x-real-ip"] },
},
databaseHooks: {
session: {
create: {
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],
}),
}));
+2
View File
@@ -0,0 +1,2 @@
export * from "./auth.js";
export * from "./patients.js";
+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;
+48
View File
@@ -0,0 +1,48 @@
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>"),
});
const parsed = schema.safeParse(process.env);
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";
+54
View File
@@ -0,0 +1,54 @@
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 { 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(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`);
});
+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";
}
}
+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);
});
+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);
}
},
);
+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 {};
+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"]
}