backend: per-doctor patient visibility, transfer, provider picker & scoped activity

- Add patients.primary_provider_id (FK to user) + migration; persist it through
  create/update and surface it on the Patient shape.
- Scope patient list/get for the `doctor` role to their own panel (with a
  createdBy fallback for legacy rows); admin/owner/member/reception/viewer keep
  seeing every patient.
- Add POST /api/patients/:fileNumber/transfer to reassign a chart (updates the
  provider link + PCP label, records activity, notifies the clinic).
- Add GET /api/staff/providers (any member) listing clinical-capable members for
  the PCP picker and transfer dialog.
- Scope the activity feed: non-admins see only their own actions; owners/admins
  see the whole clinic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-09 19:51:17 +03:00
parent 4f8793c765
commit 6a0fab97ae
11 changed files with 2697 additions and 8 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "patients" ADD COLUMN "primary_provider_id" text;--> statement-breakpoint
ALTER TABLE "patients" ADD CONSTRAINT "patients_primary_provider_id_user_id_fk" FOREIGN KEY ("primary_provider_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -64,6 +64,13 @@
"when": 1780937195597,
"tag": "0008_luxuriant_blue_blade",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781022080228,
"tag": "0009_melted_puck",
"breakpoints": true
}
]
}
+6
View File
@@ -44,6 +44,12 @@ export const patients = pgTable(
vitalsTakenAt: text("vitals_taken_at").notNull(),
vitalsTrend: jsonb("vitals_trend").$type<Trend>().notNull(),
labTrend: jsonb("lab_trend").$type<Trend>().notNull(),
// The clinician responsible for this chart (the patient's "PCP"). Used to
// scope what each doctor sees and to transfer a patient between providers.
// Nullable: legacy/unassigned rows and patients registered by reception.
primaryProviderId: text("primary_provider_id").references(() => user.id, {
onDelete: "set null",
}),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
+5
View File
@@ -55,6 +55,11 @@ export const patientInputSchema = z.object({
age: z.number().int().min(0).max(150),
sex: z.enum(["M", "F"]),
pcp: z.string(),
// Optional link to the responsible clinician (user id). Empty string ⇒ null.
primaryProviderId: z.preprocess(
(v) => (v === "" ? null : v),
z.string().nullable().optional(),
),
status: z.enum(["active", "inpatient", "discharged"]),
initials: z.string().trim().min(1).max(4),
allergies: z.array(allergySchema).default([]),
+11 -1
View File
@@ -8,9 +8,19 @@ export const activityRouter = Router();
// The audit feed is readable by any clinic member.
activityRouter.use(requireAuth, requireOrg);
// Whether the caller runs the clinic (owner/admin) and may therefore see the
// whole feed. Everyone else is scoped to their own actions.
function isClinicAdmin(memberRole: string | undefined): boolean {
return String(memberRole ?? "")
.split(",")
.map((s) => s.trim())
.some((r) => r === "owner" || r === "admin");
}
activityRouter.get("/", async (req, res, next) => {
try {
res.json(await service.listActivity(req.organizationId!));
const actorId = isClinicAdmin(req.memberRole) ? undefined : req.user!.id;
res.json(await service.listActivity(req.organizationId!, { actorId }));
} catch (err) {
next(err);
}
+82
View File
@@ -1,5 +1,9 @@
import { and, eq } from "drizzle-orm";
import { Router } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { member, user } from "../db/schema/auth.js";
import { HttpError } from "../lib/http-error.js";
import { patientInputSchema } from "../lib/patient-validation.js";
import {
@@ -15,6 +19,29 @@ import * as service from "../services/patients.js";
export const patientsRouter = Router();
const transferInputSchema = z.object({
providerId: z.string().trim().min(1),
});
// Only the `doctor` role is scoped to its own panel of patients. Any elevated
// clinical role (owner / admin / member) sees the whole clinic, so scoping never
// applies when the caller also holds one of those. Returns the user id to scope
// by, or undefined for "see everything".
function providerScope(
memberRole: string | undefined,
userId: string,
): string | undefined {
const names = String(memberRole ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!names.includes("doctor")) return undefined;
if (names.some((r) => ["owner", "admin", "member"].includes(r))) {
return undefined;
}
return userId;
}
// The `reception` role is scoped to scheduling + registration: it sees and
// writes patient demographics only, never clinical PHI. True only when the
// caller's role set is reception without any clinical-capable role.
@@ -66,6 +93,7 @@ patientsRouter.get(
await service.listPatients(
req.organizationId!,
isReceptionOnly(req.memberRole),
providerScope(req.memberRole, req.user!.id),
),
);
} catch (err) {
@@ -83,6 +111,7 @@ patientsRouter.get(
req.organizationId!,
req.params.fileNumber as string,
isReceptionOnly(req.memberRole),
providerScope(req.memberRole, req.user!.id),
);
if (!patient) throw new HttpError(404, "Patient not found.");
res.json(patient);
@@ -161,6 +190,59 @@ patientsRouter.put(
},
);
// Reassign a patient to another clinician ("transfer"). Gated on patient:write
// like any edit; the new provider must be a member of the same clinic.
patientsRouter.post(
"/:fileNumber/transfer",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const { providerId } = transferInputSchema.parse(req.body);
const [provider] = await db
.select({ name: user.name })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(
and(
eq(member.organizationId, req.organizationId!),
eq(member.userId, providerId),
),
);
if (!provider) {
throw new HttpError(400, "Selected provider is not a member of this clinic.");
}
const updated = await service.transferPatient(
req.organizationId!,
req.params.fileNumber as string,
providerId,
provider.name,
providerScope(req.memberRole, req.user!.id),
);
if (!updated) throw new HttpError(404, "Patient not found.");
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Transferred patient ${updated.name} to ${provider.name}`,
entityType: "patient",
entityId: updated.fileNumber,
patientName: updated.name,
patientFileNumber: updated.fileNumber,
});
await notifyClinic(
req.organizationId!,
{ id: req.user!.id, name: req.user!.name },
`${req.user!.name} transferred ${updated.name} to ${provider.name}`,
updated.fileNumber,
);
res.json(updated);
} catch (err) {
next(err);
}
},
);
patientsRouter.delete(
"/:fileNumber",
requirePermission({ patient: ["delete"] }),
+31 -1
View File
@@ -1,4 +1,4 @@
import { asc, eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { Router } from "express";
import { z } from "zod";
@@ -45,6 +45,36 @@ const staffInputSchema = z.object({
staffRouter.use(requireAuth, requireOrg);
// Clinical-capable roles that can be a patient's primary provider. Reception
// (front desk) and viewer (read-only) are excluded.
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
// List clinicians who can be assigned as a patient's primary provider. Readable
// by ANY clinic member (no `member:create` gate) so doctors and reception can
// pick a provider when registering/transferring a patient. Returns user ids.
staffRouter.get("/providers", async (req, res, next) => {
try {
const rows = await db
.select({
userId: member.userId,
name: user.name,
role: member.role,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(
and(
eq(member.organizationId, req.organizationId!),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
res.json(rows);
} catch (err) {
next(err);
}
});
// List the clinic's members with their usernames (the org client's
// getFullOrganization doesn't expose username). Owner/admin only.
staffRouter.get(
+14 -3
View File
@@ -1,4 +1,4 @@
import { desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { activityLog } from "../db/schema/activity.js";
@@ -54,14 +54,25 @@ export async function recordActivity(params: {
}
}
// Lists the clinic's audit feed. When `actorId` is given, only that user's own
// actions are returned (each employee sees their own activity); admins/owners
// call without it to see the whole clinic.
export async function listActivity(
orgId: string,
limit = 100,
options: { actorId?: string; limit?: number } = {},
): Promise<ActivityEntry[]> {
const { actorId, limit = 100 } = options;
const rows = await db
.select()
.from(activityLog)
.where(eq(activityLog.organizationId, orgId))
.where(
actorId
? and(
eq(activityLog.organizationId, orgId),
eq(activityLog.actorId, actorId),
)
: eq(activityLog.organizationId, orgId),
)
.orderBy(desc(activityLog.createdAt))
.limit(limit);
return rows.map(toEntry);
+52 -2
View File
@@ -1,4 +1,5 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import { and, asc, eq, inArray, isNull, or } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { db } from "../db/index.js";
import {
@@ -46,6 +47,7 @@ function toPatient(row: PatientRow, children: Children): Patient {
age: row.age,
sex: row.sex,
pcp: row.pcp,
primaryProviderId: row.primaryProviderId,
status: row.status,
initials: row.initials,
allergies: children.allergies,
@@ -106,6 +108,7 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
age: input.age,
sex: input.sex,
pcp: input.pcp,
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
alerts: input.alerts,
@@ -135,6 +138,7 @@ function demographicColumns(
age: input.age,
sex: input.sex,
pcp: input.pcp,
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
alerts: [] as string[],
@@ -158,11 +162,23 @@ function demographicUpdateColumns(input: PatientInput) {
age: input.age,
sex: input.sex,
pcp: input.pcp,
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
};
}
// Scope clinical reads to a single provider's panel: their own patients plus any
// legacy/unassigned rows they created (so a freshly scoped doctor isn't left
// with an empty list). Returns undefined when no scoping should apply.
function providerScopeFilter(providerId?: string): SQL | undefined {
if (!providerId) return undefined;
return or(
eq(patients.primaryProviderId, providerId),
and(isNull(patients.primaryProviderId), eq(patients.createdBy, providerId)),
);
}
// Loads and groups child rows for a set of patients in one round-trip each.
async function loadChildren(
patientIds: string[],
@@ -280,11 +296,15 @@ function isUniqueViolation(err: unknown): boolean {
export async function listPatients(
orgId: string,
demographicsOnly = false,
providerId?: string,
): Promise<Patient[]> {
const scope = providerScopeFilter(providerId);
const rows = await db
.select()
.from(patients)
.where(eq(patients.organizationId, orgId))
.where(
scope ? and(eq(patients.organizationId, orgId), scope) : eq(patients.organizationId, orgId),
)
.orderBy(asc(patients.name));
const children = await loadChildren(rows.map((r) => r.id));
return rows.map((r) => {
@@ -297,7 +317,9 @@ export async function getPatient(
orgId: string,
fileNumber: string,
demographicsOnly = false,
providerId?: string,
): Promise<Patient | null> {
const scope = providerScopeFilter(providerId);
const [row] = await db
.select()
.from(patients)
@@ -305,6 +327,7 @@ export async function getPatient(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
...(scope ? [scope] : []),
),
);
if (!row) return null;
@@ -313,6 +336,33 @@ export async function getPatient(
return demographicsOnly ? redactClinical(patient) : patient;
}
// Reassign a patient to another clinician. Updates both the machine link
// (primaryProviderId — drives per-doctor visibility) and the display string
// (pcp). Org-scoped; returns null when the patient isn't in this clinic.
export async function transferPatient(
orgId: string,
fileNumber: string,
providerId: string,
providerName: string,
scopeProviderId?: string,
): Promise<Patient | null> {
const scope = providerScopeFilter(scopeProviderId);
const [row] = await db
.update(patients)
.set({ primaryProviderId: providerId, pcp: providerName })
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
...(scope ? [scope] : []),
),
)
.returning();
if (!row) return null;
const children = await loadChildren([row.id]);
return toPatient(row, children.get(row.id) ?? emptyChildren());
}
export async function createPatient(
orgId: string,
userId: string,
+2 -1
View File
@@ -57,7 +57,8 @@ export type Patient = {
name: string;
age: number;
sex: Sex;
pcp: string; // primary care provider
pcp: string; // primary care provider (display name)
primaryProviderId?: string | null; // user id of the responsible clinician
status: PatientStatus;
initials: string; // for AvatarFallback
allergies: Allergy[];