mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user