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