feat: email provider, admin password reset, portal new-patient, chat pill

Chat: history pill now shows a History icon + a Start-new-chat (SquarePen)
button; removed the duplicate chat-history list from the sidebar.

Email: deployment-wide email provider config (Resend/Postmark/SendGrid/SMTP) in
Settings → Developers, with encrypted API key and a Send-test action. sendEmail
dispatches via the chosen provider (REST via fetch; SMTP via nodemailer).

Forgot password with no provider: alert the clinic admin(s) via a "System"
message card in Messages + a bell notification (seeded system user + per-clinic
System conversation); clicking deep-links to /settings?tab=careTeam&member=<id>.
Admins can set a member's password directly from the employee dialog
(PATCH /api/staff/:id/password via Better Auth's internal context — no admin
plugin needed).

Patient Portal: "New patient" booking path registers a demographics-only patient
then books; bookings reject double-booked slots (409).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 19:52:55 +03:00
parent 516de6ad60
commit 90e6ec4cc0
29 changed files with 5200 additions and 142 deletions
+19 -4
View File
@@ -35,10 +35,25 @@ export const auth = betterAuth({
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.`,
// With a provider configured, email the reset link. Otherwise fall back to
// alerting the clinic admin(s) so they can set a new password (dynamic
// imports keep the Better Auth CLI's static graph minimal at generate time).
const { isEmailConfigured } = await import("./services/email-config.js");
if (await isEmailConfigured()) {
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.`,
});
return;
}
const { notifyAdminsPasswordReset } = await import(
"./services/auth-fallback.js"
);
await notifyAdminsPasswordReset({
id: user.id,
name: user.name,
email: user.email,
});
},
},
+19
View File
@@ -0,0 +1,19 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
// Deployment-wide email-provider configuration — a single row (id = "default").
// Email (verification, password reset, invitations) is sent while the user is
// logged out / before any clinic exists, so this can't be per-clinic; it's one
// config for the whole deployment, set by any clinic admin from Settings →
// Developers. The provider's API key is stored encrypted (lib/crypto.ts).
export const emailSettings = pgTable("email_settings", {
id: text("id").primaryKey().default("default"),
// "none" | "smtp" | "resend" | "postmark" | "sendgrid"
provider: text("provider").notNull().default("none"),
fromAddress: text("from_address").notNull().default(""),
// Encrypted API key (Resend/Postmark/SendGrid). SMTP uses env credentials.
credentials: text("credentials"),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+1
View File
@@ -11,6 +11,7 @@ export * from "./activity.js";
export * from "./messaging.js";
export * from "./notifications.js";
export * from "./settings.js";
export * from "./email-settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
+126 -26
View File
@@ -1,6 +1,7 @@
import nodemailer from "nodemailer";
import { env } from "../env.js";
import { getActiveConfig } from "../services/email-config.js";
type SendArgs = {
to: string;
@@ -9,10 +10,9 @@ type SendArgs = {
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
// SMTP transport (built from env) — used when the active provider is "smtp" or,
// for backward compatibility, when SMTP_HOST is set and no provider is chosen.
const smtpTransport = env.SMTP_HOST
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ?? 587,
@@ -24,26 +24,126 @@ const transport = env.SMTP_HOST
})
: 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,
});
function logToConsole({ to, subject, text }: SendArgs): void {
console.info(
[
"",
"✉️ [email:console] No email provider configured — printing instead of sending.",
` to: ${to}`,
` subject: ${subject}`,
` body: ${text}`,
"",
].join("\n"),
);
}
async function sendViaResend(
apiKey: string,
from: string,
{ to, subject, text, html }: SendArgs,
): Promise<void> {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from, to, subject, text, html: html ?? text }),
});
if (!res.ok) throw new Error(`Resend failed: ${res.status} ${await res.text()}`);
}
async function sendViaPostmark(
apiKey: string,
from: string,
{ to, subject, text, html }: SendArgs,
): Promise<void> {
const res = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"X-Postmark-Server-Token": apiKey,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
From: from,
To: to,
Subject: subject,
TextBody: text,
HtmlBody: html ?? text,
MessageStream: "outbound",
}),
});
if (!res.ok)
throw new Error(`Postmark failed: ${res.status} ${await res.text()}`);
}
async function sendViaSendgrid(
apiKey: string,
from: string,
{ to, subject, text, html }: SendArgs,
): Promise<void> {
const res = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: to }] }],
from: { email: from },
subject,
content: [
{ type: "text/plain", value: text },
{ type: "text/html", value: html ?? text },
],
}),
});
if (!res.ok)
throw new Error(`SendGrid failed: ${res.status} ${await res.text()}`);
}
// Send an email via the deployment's configured provider. Falls back to logging
// when nothing is configured so local/open-source dev needs zero setup.
export async function sendEmail(args: SendArgs): Promise<void> {
const cfg = await getActiveConfig();
// A real "from": the configured address, else the SMTP default.
const from = cfg.fromAddress || env.SMTP_FROM;
switch (cfg.provider) {
case "resend":
if (!cfg.credentials) return logToConsole(args);
return sendViaResend(cfg.credentials, from, args);
case "postmark":
if (!cfg.credentials) return logToConsole(args);
return sendViaPostmark(cfg.credentials, from, args);
case "sendgrid":
if (!cfg.credentials) return logToConsole(args);
return sendViaSendgrid(cfg.credentials, from, args);
case "smtp": {
if (!smtpTransport) return logToConsole(args);
await smtpTransport.sendMail({
from,
to: args.to,
subject: args.subject,
text: args.text,
html: args.html ?? args.text,
});
return;
}
default: {
// No provider chosen — honour a pre-existing SMTP env setup if present.
if (smtpTransport) {
await smtpTransport.sendMail({
from,
to: args.to,
subject: args.subject,
text: args.text,
html: args.html ?? args.text,
});
return;
}
return logToConsole(args);
}
}
}
+53 -1
View File
@@ -7,9 +7,10 @@ import { organization } from "../db/schema/auth.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import { recordActivity } from "../services/activity.js";
import { createAppointment, listAppointments } from "../services/appointments.js";
import { getPatient } from "../services/patients.js";
import { createPatient, getPatient } from "../services/patients.js";
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
// waiting room). Scoped by the clinic slug in the URL — there is no session.
@@ -52,6 +53,39 @@ const bookingSchema = z.object({
type: z.string().trim().max(120).optional(),
});
const newPatientSchema = z.object({
name: z.string().trim().min(1, "Your name is required.").max(200),
sex: z.string().trim().optional(),
age: z.coerce.number().int().min(0).max(150).optional(),
});
// POST /api/portal/:clinic/patients — register a new (demographics-only) patient
// from the kiosk so a first-time visitor can get a file number and then book.
// Writes only demographics (no clinical PHI) from this unauthenticated surface.
portalRouter.post("/:clinic/patients", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const body = newPatientSchema.parse(req.body);
const input = patientInputSchema.parse({
name: body.name,
sex: body.sex ?? "M",
age: body.age ?? 0,
source: "manual",
});
const created = await createPatient(clinic.id, "", input, true);
await recordActivity({
orgId: clinic.id,
actor: { id: "", name: created.name },
action: `Patient portal registration — ${created.name}`,
entityType: "patient",
entityId: created.fileNumber,
});
res.status(201).json({ fileNumber: created.fileNumber, name: created.name });
} catch (err) {
next(err);
}
});
// POST /api/portal/:clinic/appointments — self-service booking for a registered
// patient. Verifies the file number + name, then creates a confirmed appointment
// that shows up on the clinic's Appointments page.
@@ -84,6 +118,24 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
status: "confirmed",
source: "manual",
});
// Prevent double-booking the same slot: a provider can't have two
// appointments at the same date+time (clinic-wide when the provider is
// unknown). Cancelled appointments don't count.
const taken = (await listAppointments(clinic.id)).some(
(a) =>
a.status !== "cancelled" &&
a.date === input.date &&
a.time === input.time &&
(!input.provider || !a.provider || a.provider === input.provider),
);
if (taken) {
throw new HttpError(
409,
"That time slot is already taken. Please choose another time.",
);
}
const created = await createAppointment(clinic.id, "", input);
await recordActivity({
orgId: clinic.id,
+75
View File
@@ -11,7 +11,13 @@ import {
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { sendEmail } from "../lib/email.js";
import { recordActivity } from "../services/activity.js";
import {
type EmailProvider,
getPublicConfig,
saveConfig,
} from "../services/email-config.js";
import { createPatient, listPatients } from "../services/patients.js";
export const settingsRouter = Router();
@@ -20,6 +26,75 @@ export const settingsRouter = Router();
// no active organization or RBAC permission.
settingsRouter.use(requireAuth);
// --- Email provider (deployment-wide, admin-only) ------------------------
// One config for the whole deployment (email is sent while logged out, so it
// can't be per-clinic). Gated by `member: ["create"]` — any clinic admin sets
// the deployment's provider. The API key is never returned.
const emailConfigSchema = z.object({
provider: z.enum(["none", "smtp", "resend", "postmark", "sendgrid"]),
fromAddress: z.string().trim().max(200).default(""),
// undefined = leave key as-is; "" = clear; string = set/replace.
credentials: z.string().trim().max(500).optional(),
});
settingsRouter.get(
"/email",
requireOrg,
requirePermission({ member: ["create"] }),
async (_req, res, next) => {
try {
res.json(await getPublicConfig());
} catch (err) {
next(err);
}
},
);
settingsRouter.put(
"/email",
requireOrg,
requirePermission({ member: ["create"] }),
async (req, res, next) => {
try {
const input = emailConfigSchema.parse(req.body);
const saved = await saveConfig({
provider: input.provider as EmailProvider,
fromAddress: input.fromAddress,
credentials: input.credentials,
});
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Updated email provider — ${saved.provider}`,
entityType: "settings",
entityId: "email",
});
res.json(saved);
} catch (err) {
next(err);
}
},
);
settingsRouter.post(
"/email/test",
requireOrg,
requirePermission({ member: ["create"] }),
async (req, res, next) => {
try {
await sendEmail({
to: req.user!.email,
subject: "temetro email test",
text: `This is a test email from temetro. If you received it, your email provider is configured correctly.`,
});
res.json({ ok: true, to: req.user!.email });
} catch (err) {
next(err);
}
},
);
// --- Records import / export (clinic-wide, admin-only) -------------------
// Gated by `member: ["create"]` — the same admin/owner marker the staff route
// uses — so only clinic admins can bulk-move records.
+39
View File
@@ -230,3 +230,42 @@ staffRouter.patch(
}
},
);
// Set a member's password directly (admin-driven reset — e.g. the employee
// forgot it and no email provider is configured). Owner/admin only, and the
// target must be a member of this clinic. Uses Better Auth's internal context to
// hash + store the password (the same calls its admin plugin makes), so no admin
// plugin is required.
const passwordInputSchema = z.object({
newPassword: z.string().min(12).max(256),
});
staffRouter.patch(
"/:userId/password",
requirePermission({ member: ["update"] }),
async (req, res, next) => {
try {
const userId = String(req.params.userId ?? "");
const { newPassword } = passwordInputSchema.parse(req.body);
const [target] = await db
.select({ id: member.id })
.from(member)
.where(
and(
eq(member.organizationId, req.organizationId!),
eq(member.userId, userId),
),
);
if (!target) throw new HttpError(404, "Member not found.");
const ctx = await auth.$context;
const hashed = await ctx.password.hash(newPassword);
await ctx.internalAdapter.updatePassword(userId, hashed);
res.json({ ok: true });
} catch (err) {
next(err);
}
},
);
+68
View File
@@ -0,0 +1,68 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/index.js";
import { member } from "../db/schema/auth.js";
import { emitToUser } from "../realtime.js";
import { createSystemMessage } from "./messaging.js";
import { createNotification } from "./notifications.js";
// When an employee asks for a password reset but no email provider is configured,
// alert the admin(s) of each clinic the user belongs to: a "System" message card
// in Messages + a bell notification, both deep-linking to that member's settings
// so an admin can set a new password. The reset URL is never exposed.
export async function notifyAdminsPasswordReset(u: {
id: string;
name: string;
email: string;
}): Promise<void> {
const orgs = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, u.id));
const orgIds = [...new Set(orgs.map((o) => o.organizationId))];
for (const orgId of orgIds) {
try {
const admins = await db
.select({ userId: member.userId })
.from(member)
.where(
and(
eq(member.organizationId, orgId),
inArray(member.role, ["owner", "admin"]),
),
);
const adminIds = admins.map((a) => a.userId).filter((id) => id !== u.id);
if (adminIds.length === 0) continue;
const body = `${u.name} requested a password reset, but no email provider is configured. Reset their password from their settings.`;
const { message, recipientIds } = await createSystemMessage(
orgId,
adminIds,
body,
{
kind: "passwordReset",
userId: u.id,
userName: u.name,
userEmail: u.email,
},
);
for (const rid of recipientIds) emitToUser(rid, "message:new", message);
for (const rid of adminIds) {
const n = await createNotification({
orgId,
userId: rid,
type: "password_reset",
text: `${u.name} needs a password reset`,
entityType: "user",
entityId: u.id,
actorName: u.name,
});
if (n) emitToUser(rid, "notification:new", n);
}
} catch (err) {
console.error("password-reset fallback failed:", err);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { emailSettings } from "../db/schema/email-settings.js";
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
export type EmailProvider = "none" | "smtp" | "resend" | "postmark" | "sendgrid";
const ROW_ID = "default";
// Providers that authenticate with an API key (so the UI knows to ask for one).
const API_KEY_PROVIDERS: EmailProvider[] = ["resend", "postmark", "sendgrid"];
export type PublicEmailConfig = {
provider: EmailProvider;
fromAddress: string;
hasCredentials: boolean;
};
export type ActiveEmailConfig = {
provider: EmailProvider;
fromAddress: string;
credentials: string | null;
};
async function getRow() {
const [row] = await db
.select()
.from(emailSettings)
.where(eq(emailSettings.id, ROW_ID))
.limit(1);
return row ?? null;
}
export async function getPublicConfig(): Promise<PublicEmailConfig> {
const row = await getRow();
return {
provider: (row?.provider as EmailProvider) ?? "none",
fromAddress: row?.fromAddress ?? "",
hasCredentials: Boolean(row?.credentials),
};
}
// Internal — includes the decrypted API key. Used by lib/email.ts at send time.
export async function getActiveConfig(): Promise<ActiveEmailConfig> {
const row = await getRow();
return {
provider: (row?.provider as EmailProvider) ?? "none",
fromAddress: row?.fromAddress ?? "",
credentials: row?.credentials ? decryptSecret(row.credentials) : null,
};
}
// True when the deployment can actually deliver email (a real provider is set,
// and API-key providers have a key). SMTP relies on env, treated as configured.
export async function isEmailConfigured(): Promise<boolean> {
const cfg = await getActiveConfig();
if (cfg.provider === "none") return false;
if (API_KEY_PROVIDERS.includes(cfg.provider)) return Boolean(cfg.credentials);
return true; // smtp
}
export async function saveConfig(input: {
provider: EmailProvider;
fromAddress: string;
// undefined = leave existing key untouched; "" = clear it.
credentials?: string;
}): Promise<PublicEmailConfig> {
const existing = await getRow();
const credentials =
input.credentials === undefined
? (existing?.credentials ?? null)
: input.credentials
? encryptSecret(input.credentials)
: null;
await db
.insert(emailSettings)
.values({
id: ROW_ID,
provider: input.provider,
fromAddress: input.fromAddress,
credentials,
})
.onConflictDoUpdate({
target: emailSettings.id,
set: {
provider: input.provider,
fromAddress: input.fromAddress,
credentials,
updatedAt: new Date(),
},
});
return getPublicConfig();
}
+105
View File
@@ -482,6 +482,111 @@ export async function markRead(
);
}
// --- System messages -------------------------------------------------------
// A reserved user that "sends" system messages. It has no account row, so it can
// never log in; the messages.senderId FK just needs a user to exist.
export const SYSTEM_USER_ID = "system";
export const SYSTEM_USER_NAME = "temetro System";
async function ensureSystemUser(): Promise<void> {
await db
.insert(user)
.values({
id: SYSTEM_USER_ID,
name: SYSTEM_USER_NAME,
email: "system@temetro.local",
emailVerified: true,
})
.onConflictDoNothing();
}
// Ensure the per-clinic "System" conversation exists and includes the system
// user plus the given recipients, returning its id.
async function ensureSystemConversation(
orgId: string,
recipientIds: string[],
): Promise<string> {
await ensureSystemUser();
const [existing] = await db
.select({ id: conversations.id })
.from(conversations)
.where(
and(
eq(conversations.organizationId, orgId),
eq(conversations.name, "System"),
eq(conversations.createdBy, SYSTEM_USER_ID),
),
)
.limit(1);
let convId = existing?.id;
if (!convId) {
const [created] = await db
.insert(conversations)
.values({
organizationId: orgId,
name: "System",
isGroup: true,
createdBy: SYSTEM_USER_ID,
})
.returning();
convId = created!.id;
}
const current = new Set(await participantIds(convId));
const toAdd = [SYSTEM_USER_ID, ...recipientIds].filter(
(id) => !current.has(id),
);
if (toAdd.length > 0) {
await db
.insert(conversationParticipants)
.values(
toAdd.map((uid) => ({
conversationId: convId!,
userId: uid,
lastReadAt: uid === SYSTEM_USER_ID ? new Date() : null,
})),
)
.onConflictDoNothing();
}
return convId;
}
// Post a message from the system user into the clinic's System conversation.
export async function createSystemMessage(
orgId: string,
recipientIds: string[],
body: string,
attachment: MessageAttachment,
): Promise<{ message: ConversationMessage; recipientIds: string[] }> {
const convId = await ensureSystemConversation(orgId, recipientIds);
const now = new Date();
const [row] = await db
.insert(messages)
.values({
conversationId: convId,
senderId: SYSTEM_USER_ID,
body,
attachments: [attachment],
})
.returning();
await db
.update(conversations)
.set({ updatedAt: now })
.where(eq(conversations.id, convId));
return {
message: {
id: row!.id,
conversationId: convId,
senderId: SYSTEM_USER_ID,
senderName: SYSTEM_USER_NAME,
body: row!.body,
attachments: row!.attachments,
createdAt: row!.createdAt.toISOString(),
},
recipientIds,
};
}
export async function listClinicMembers(
orgId: string,
excludeUserId: string,
+2 -1
View File
@@ -9,7 +9,8 @@ export type ActivityEntityType =
| "invoice"
| "inventory"
| "dispense"
| "task";
| "task"
| "settings";
export type ActivityEntry = {
id: string;
+10 -1
View File
@@ -25,7 +25,16 @@ export type MessageAttachment =
mimeType: string;
size: number;
}
| { kind: "appointment"; appointment: AppointmentSnapshot };
| { kind: "appointment"; appointment: AppointmentSnapshot }
// A system-generated alert (e.g. an employee asked for a password reset but no
// email provider is configured). Rendered as a distinct "System" card that
// deep-links an admin to the member's settings.
| {
kind: "passwordReset";
userId: string;
userName: string;
userEmail: string;
};
export type ConversationMessage = {
id: string;