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
+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,