mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
@@ -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>"
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
coverage
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1780413912874,
|
||||
"tag": "0000_small_sir_ram",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+5348
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./auth.js";
|
||||
export * from "./patients.js";
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+22
@@ -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 {};
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user