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
+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[];
};