mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 11:36:52 +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,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[];
|
||||
};
|
||||
Reference in New Issue
Block a user