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
+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;