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
+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);
}
},
);