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