Files
temetro/backend/src/routes/settings.ts
T
Khalid Abdi 90e6ec4cc0 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>
2026-06-20 19:52:55 +03:00

226 lines
6.6 KiB
TypeScript

import { eq } from "drizzle-orm";
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { userSettings } from "../db/schema/settings.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import { settingsInputSchema } from "../lib/settings-validation.js";
import {
requireAuth,
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();
// Settings are per-user (not per-clinic), so only authentication is required —
// 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.
// Download every patient record in the active clinic as one JSON archive.
settingsRouter.get(
"/records/export",
requireOrg,
requirePermission({ member: ["create"] }),
async (req, res, next) => {
try {
const patients = await listPatients(req.organizationId!);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Exported ${patients.length} patient record(s)`,
entityType: "patient",
entityId: "export",
});
res.json({
temetroExport: true,
version: 1,
exportedAt: new Date().toISOString(),
organizationId: req.organizationId,
patientCount: patients.length,
patients,
});
} catch (err) {
next(err);
}
},
);
// Import a previously exported archive. Creates new patients and skips any whose
// file number already exists in this clinic (idempotent re-imports). Cross-clinic
// provider links are dropped — they reference users this clinic doesn't have.
const importBodySchema = z.object({
patients: z.array(z.unknown()).max(10_000),
});
settingsRouter.post(
"/records/import",
requireOrg,
requirePermission({ member: ["create"] }),
async (req, res, next) => {
try {
const { patients: incoming } = importBodySchema.parse(req.body);
const existing = new Set(
(await listPatients(req.organizationId!)).map((p) => p.fileNumber),
);
let created = 0;
let skipped = 0;
const errors: string[] = [];
for (const raw of incoming) {
// Provider links are clinic-specific; drop them so the FK holds.
const candidate =
raw && typeof raw === "object"
? { ...(raw as Record<string, unknown>), primaryProviderId: null }
: raw;
const parsed = patientInputSchema.safeParse(candidate);
if (!parsed.success) {
if (errors.length < 20) {
const name =
(candidate as { name?: string })?.name ?? "(unknown)";
errors.push(`${name}: ${parsed.error.issues[0]?.message ?? "invalid"}`);
}
continue;
}
if (parsed.data.fileNumber && existing.has(parsed.data.fileNumber)) {
skipped += 1;
continue;
}
try {
const made = await createPatient(
req.organizationId!,
req.user!.id,
parsed.data,
);
existing.add(made.fileNumber);
created += 1;
} catch {
skipped += 1;
}
}
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Imported ${created} patient record(s)`,
entityType: "patient",
entityId: "import",
});
res.json({ created, skipped, total: incoming.length, errors });
} catch (err) {
next(err);
}
},
);
settingsRouter.get("/", async (req, res, next) => {
try {
const rows = await db
.select({ preferences: userSettings.preferences })
.from(userSettings)
.where(eq(userSettings.userId, req.user!.id))
.limit(1);
res.json({ preferences: rows[0]?.preferences ?? {} });
} catch (err) {
next(err);
}
});
settingsRouter.put("/", async (req, res, next) => {
try {
const { preferences } = settingsInputSchema.parse(req.body);
await db
.insert(userSettings)
.values({ userId: req.user!.id, preferences })
.onConflictDoUpdate({
target: userSettings.userId,
set: { preferences, updatedAt: new Date() },
});
res.json({ preferences });
} catch (err) {
next(err);
}
});