mirror of
https://github.com/temetro/temetro.git
synced 2026-08-22 07:56:38 +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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user