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