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