backend: add per-clinic staff specialty (Care Team -> patient sheet)

New staff_profile table (org + user, unique) holding a clinician's clinical
specialty. GET /api/staff and /api/staff/providers now include specialty; new
PATCH /api/staff/:userId (member:update) upserts it. Migration 0023.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-19 20:28:52 +03:00
parent eb94c1549a
commit 2f36875d37
12 changed files with 4156 additions and 48 deletions
@@ -0,0 +1,10 @@
CREATE TABLE "staff_profile" (
"organization_id" text NOT NULL,
"user_id" text NOT NULL,
"specialty" text,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "staff_profile" ADD CONSTRAINT "staff_profile_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_profile" ADD CONSTRAINT "staff_profile_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "staff_profile_org_user_idx" ON "staff_profile" USING btree ("organization_id","user_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -162,6 +162,13 @@
"when": 1781802557475,
"tag": "0022_damp_synch",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1781889806890,
"tag": "0023_panoramic_punisher",
"breakpoints": true
}
]
}
+1
View File
@@ -16,3 +16,4 @@ export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
export * from "./attachments.js";
export * from "./integrations.js";
export * from "./staff-profile.js";
+31
View File
@@ -0,0 +1,31 @@
import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
// Per-member, per-clinic profile extras that Better Auth's member row doesn't
// hold. Currently just a doctor's clinical specialty (e.g. "Orthopedist",
// "Dentist") which the admin sets in Care Team and surfaces on the patient
// sheet for the patient's primary provider. Unique on (org, user) so each
// member has at most one profile per clinic.
export const staffProfile = pgTable(
"staff_profile",
{
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
specialty: text("specialty"),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
uniqueIndex("staff_profile_org_user_idx").on(
table.organizationId,
table.userId,
),
],
);
+62
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { auth } from "../auth.js";
import { db } from "../db/index.js";
import { member, organization, user } from "../db/schema/auth.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { HttpError } from "../lib/http-error.js";
import { requireAuth, requireOrg, requirePermission } from "../middleware/auth.js";
@@ -65,9 +66,17 @@ staffRouter.get("/providers", async (req, res, next) => {
userId: member.userId,
name: user.name,
role: member.role,
specialty: staffProfile.specialty,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, req.organizationId!),
@@ -96,9 +105,17 @@ staffRouter.get(
name: user.name,
email: user.email,
username: user.username,
specialty: staffProfile.specialty,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(eq(member.organizationId, req.organizationId!))
.orderBy(asc(user.name));
res.json(rows);
@@ -168,3 +185,48 @@ staffRouter.post(
}
},
);
// Update a member's clinical specialty. Empty string clears it. Owner/admin
// only. Upserts the per-clinic staff_profile row.
const specialtyInputSchema = z.object({
specialty: z.preprocess(
(v) => (typeof v === "string" && v.trim() === "" ? null : v),
z.string().trim().max(60).nullable(),
),
});
staffRouter.patch(
"/:userId",
requirePermission({ member: ["update"] }),
async (req, res, next) => {
try {
const userId = String(req.params.userId ?? "");
const { specialty } = specialtyInputSchema.parse(req.body);
const organizationId = req.organizationId!;
// The target must be a member of this clinic.
const [target] = await db
.select({ id: member.id })
.from(member)
.where(
and(
eq(member.organizationId, organizationId),
eq(member.userId, userId),
),
);
if (!target) throw new HttpError(404, "Member not found.");
await db
.insert(staffProfile)
.values({ organizationId, userId, specialty })
.onConflictDoUpdate({
target: [staffProfile.organizationId, staffProfile.userId],
set: { specialty },
});
res.json({ userId, specialty });
} catch (err) {
next(err);
}
},
);
+57 -45
View File
@@ -626,11 +626,11 @@ export function MessagesView() {
</div>
<form
className="flex flex-col gap-2 rounded-2xl border bg-card/30 p-2"
className="flex flex-col rounded-3xl border border-input bg-background shadow-sm transition-colors focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/20"
onSubmit={send}
>
{pending.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 px-1 pt-1">
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-3">
{pending.map((att, i) => (
<span
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-foreground text-xs"
@@ -658,52 +658,64 @@ export function MessagesView() {
))}
</div>
)}
<div className="flex items-center gap-2">
{/* The attach control is a native <label> wrapping the file
input, so the browser opens the picker on a trusted click.
A programmatic `inputRef.click()` (the old menu item) gets
dropped by user-activation gating once the menu closes —
which is why attaching silently failed and no chip appeared. */}
<label
aria-label={t("messages.attach.file")}
className={cn(
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
uploading && "pointer-events-none opacity-50",
)}
>
<Paperclip className="size-4" />
<input
aria-label={t("messages.attach.file")}
className="sr-only"
multiple
onChange={onPickFiles}
ref={fileInputRef}
type="file"
/>
</label>
<Button
aria-label={t("messages.attach.appointment")}
disabled={uploading}
onClick={openApptPicker}
size="icon"
type="button"
variant="ghost"
>
<CalendarClock className="size-4" />
</Button>
<Input
aria-label={t("messages.newMessage")}
className="border-0 bg-transparent shadow-none before:hidden"
onChange={(e) => setDraft(e.target.value)}
placeholder={
uploading
? t("messages.attach.uploading")
: t("messages.messagePlaceholder", { name: selected.name })
<textarea
aria-label={t("messages.newMessage")}
className="field-sizing-content block max-h-40 min-h-11 w-full resize-none bg-transparent px-4 pt-3 pb-1 text-sm outline-none placeholder:text-muted-foreground"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter sends; Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
value={draft}
/>
}}
placeholder={
uploading
? t("messages.attach.uploading")
: t("messages.messagePlaceholder", { name: selected.name })
}
rows={1}
value={draft}
/>
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<div className="flex items-center gap-0.5">
{/* The attach control is a native <label> wrapping the file
input, so the browser opens the picker on a trusted click.
A programmatic `inputRef.click()` (the old menu item) gets
dropped by user-activation gating once the menu closes —
which is why attaching silently failed and no chip appeared. */}
<label
aria-label={t("messages.attach.file")}
className={cn(
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
uploading && "pointer-events-none opacity-50",
)}
>
<Paperclip className="size-4" />
<input
aria-label={t("messages.attach.file")}
className="sr-only"
multiple
onChange={onPickFiles}
ref={fileInputRef}
type="file"
/>
</label>
<Button
aria-label={t("messages.attach.appointment")}
className="rounded-full"
disabled={uploading}
onClick={openApptPicker}
size="icon"
type="button"
variant="ghost"
>
<CalendarClock className="size-4" />
</Button>
</div>
<Button
aria-label={t("messages.send")}
className="size-9 shrink-0 rounded-full"
disabled={!draft.trim() && pending.length === 0}
size="icon"
type="submit"
@@ -1,7 +1,7 @@
"use client";
import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
@@ -27,6 +27,7 @@ import {
} from "@/lib/invoices";
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
import type { Prescription } from "@/lib/prescriptions";
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
import { cn } from "@/lib/utils";
// A record "file" surfaced both in the graph and the sheet's clickable list.
@@ -132,6 +133,24 @@ export function PatientDetail({
// The record "file" opened in a detail dialog from the records list.
const [openFile, setOpenFile] = useState<RecordFile | null>(null);
// Resolve the responsible clinician's specialty (set by an admin in Care
// Team) to show alongside the primary-care provider.
const [providers, setProviders] = useState<Provider[]>([]);
useEffect(() => {
if (!patient.primaryProviderId) return;
let active = true;
listProviders()
.then((p) => active && setProviders(p))
.catch(() => {});
return () => {
active = false;
};
}, [patient.primaryProviderId]);
const providerSpecialty = specialtyLabel(
t,
providers.find((p) => p.userId === patient.primaryProviderId)?.specialty,
);
// The same problems + visits the graph plots, as a clickable list.
const files: RecordFile[] = [
...patient.problems.map((p, i) => ({
@@ -216,7 +235,9 @@ export function PatientDetail({
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Stat
label={t("patientCard.summary.primaryCare")}
value={patient.pcp}
value={
providerSpecialty ? `${patient.pcp} · ${providerSpecialty}` : patient.pcp
}
/>
<Stat
label={t("patientCard.summary.lastSeen")}
@@ -34,8 +34,12 @@ import {
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles";
import { SPECIALTIES, specialtyLabel, updateStaffSpecialty } from "@/lib/staff";
import { notify } from "@/lib/toast";
// Roles that can carry a clinical specialty (i.e. treat patients).
const PROVIDER_ROLES = new Set(["owner", "admin", "doctor", "member"]);
// Icon shown next to each permission resource row.
const RESOURCE_ICONS: Record<string, React.ReactNode> = {
patient: <Users className="size-4" />,
@@ -53,6 +57,7 @@ export type StaffMember = {
name: string | null;
email: string | null;
username: string | null;
specialty: string | null;
};
function roleLabel(role?: string | null): string {
@@ -98,10 +103,13 @@ export function EmployeeDetailDialog({
const { t } = useTranslation();
const [role, setRole] = useState<string>(member?.role ?? "");
const [saving, setSaving] = useState(false);
const [specialty, setSpecialty] = useState<string>(member?.specialty ?? "");
const [savingSpecialty, setSavingSpecialty] = useState(false);
useEffect(() => {
setRole(member?.role ?? "");
}, [member?.id, member?.role]);
setSpecialty(member?.specialty ?? "");
}, [member?.id, member?.role, member?.specialty]);
const summary = rolePermissionSummary(member?.role);
const secondary = member?.username ? `@${member.username}` : member?.email;
@@ -137,6 +145,39 @@ export function EmployeeDetailDialog({
onOpenChange(false);
};
const saveSpecialty = async () => {
if (!member || savingSpecialty) return;
const next = specialty || null;
if (next === (member.specialty ?? null)) return;
setSavingSpecialty(true);
try {
await updateStaffSpecialty(member.userId, next);
notify.success(
t("settings.careTeam.employee.specialtyUpdatedTitle"),
t("settings.careTeam.employee.specialtyUpdatedBody", {
name: member.name ?? member.email ?? "",
}),
);
onChanged();
} catch {
notify.error(
t("settings.careTeam.employee.specialtyFailedTitle"),
t("settings.careTeam.employee.specialtyFailedBody"),
);
} finally {
setSavingSpecialty(false);
}
};
const showSpecialty = PROVIDER_ROLES.has(member?.role ?? "");
const specialtyOptions = [
{ value: "", label: t("settings.careTeam.employee.noSpecialty") },
...SPECIALTIES.map((s) => ({
value: s,
label: t(`settings.careTeam.specialties.${s}`),
})),
];
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
@@ -163,6 +204,11 @@ export function EmployeeDetailDialog({
{secondary}
</p>
)}
{showSpecialty && specialtyLabel(t, member?.specialty) && (
<p className="truncate text-primary text-xs">
{specialtyLabel(t, member?.specialty)}
</p>
)}
</div>
<Badge className="capitalize" variant="secondary">
{roleLabel(member?.role)}
@@ -246,6 +292,47 @@ export function EmployeeDetailDialog({
</div>
</div>
)}
{editable && showSpecialty && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("settings.careTeam.employee.specialty")}
</span>
<div className="flex items-center gap-2">
<Select
items={specialtyOptions}
onValueChange={(value) => setSpecialty(value as string)}
value={specialty}
>
<SelectTrigger
aria-label={t("settings.careTeam.employee.specialty")}
className="flex-1"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{specialtyOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectPopup>
</Select>
<Button
disabled={
savingSpecialty ||
(specialty || null) === (member?.specialty ?? null)
}
onClick={saveSpecialty}
type="button"
>
{savingSpecialty
? t("settings.careTeam.employee.saving")
: t("settings.careTeam.employee.save")}
</Button>
</div>
</div>
)}
</DialogPanel>
<DialogFooter>
@@ -9,6 +9,7 @@ import {
EmployeeDetailDialog,
type StaffMember,
} from "@/components/settings/employee-detail-dialog";
import { specialtyLabel } from "@/lib/staff";
import {
SettingsCard,
SettingsSection,
@@ -162,6 +163,11 @@ export function CareTeamPanel() {
</p>
)}
</div>
{specialtyLabel(t, m.specialty) && (
<Badge variant="outline">
{specialtyLabel(t, m.specialty)}
</Badge>
)}
<Badge className="capitalize" variant="secondary">
{roleLabel(m.role)}
</Badge>
@@ -1506,6 +1506,22 @@
"clickHint": "Tip: click a team member to see their permissions and manage their role.",
"addMember": "Add team member",
"removeMember": "Remove member",
"specialties": {
"general": "General practitioner",
"orthopedics": "Orthopedist",
"dentistry": "Dentist",
"cardiology": "Cardiologist",
"pediatrics": "Pediatrician",
"dermatology": "Dermatologist",
"neurology": "Neurologist",
"obgyn": "OB-GYN",
"ophthalmology": "Ophthalmologist",
"ent": "ENT specialist",
"psychiatry": "Psychiatrist",
"radiology": "Radiologist",
"anesthesiology": "Anesthesiologist",
"surgery": "Surgeon"
},
"employee": {
"title": "Team member",
"description": "View this member's access, change their role, or remove them.",
@@ -1520,6 +1536,12 @@
"roleUpdatedBody": "{{name}} is now {{role}}.",
"roleFailedTitle": "Couldn't update role",
"roleFailedBody": "Please try again.",
"specialty": "Specialty",
"noSpecialty": "No specialty",
"specialtyUpdatedTitle": "Specialty updated",
"specialtyUpdatedBody": "Updated {{name}}'s specialty.",
"specialtyFailedTitle": "Couldn't update specialty",
"specialtyFailedBody": "Please try again.",
"resources": {
"patient": "Patients",
"appointment": "Appointments",
+46
View File
@@ -7,8 +7,54 @@ export type Provider = {
userId: string;
name: string;
role: string;
// Admin-set clinical specialty (e.g. "Orthopedist"); null when unset.
specialty?: string | null;
};
export function listProviders(): Promise<Provider[]> {
return apiFetch<Provider[]>("/api/staff/providers");
}
// Update a member's clinical specialty (owner/admin only). Pass null to clear.
export function updateStaffSpecialty(
userId: string,
specialty: string | null,
): Promise<{ userId: string; specialty: string | null }> {
return apiFetch(`/api/staff/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify({ specialty }),
});
}
// Curated list of clinical specialties an admin can assign to a doctor. Stored
// as the plain string; values double as the i18n key suffix
// (settings.careTeam.specialties.*).
// Human label for a stored specialty key (settings.careTeam.specialties.*),
// falling back to the raw value for any legacy/free-text entries.
export function specialtyLabel(
t: (key: string) => string,
specialty?: string | null,
): string | null {
if (!specialty) return null;
const key = `settings.careTeam.specialties.${specialty}`;
const label = t(key);
return label === key ? specialty : label;
}
export const SPECIALTIES = [
"general",
"orthopedics",
"dentistry",
"cardiology",
"pediatrics",
"dermatology",
"neurology",
"obgyn",
"ophthalmology",
"ent",
"psychiatry",
"radiology",
"anesthesiology",
"surgery",
] as const;
export type Specialty = (typeof SPECIALTIES)[number];