mirror of
https://github.com/temetro/temetro.git
synced 2026-08-17 22:07:48 +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,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;
|
||||
Reference in New Issue
Block a user