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