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