feat(ai): clinic-wide AI kill-switch (admin-controlled)

- new org_ai_policy table + GET/PUT /api/ai/policy (read for any member,
  write owner/admin only); migration 0018
- /api/chat hard-blocks (403) when AI is off for the caller
- Settings → AI "Availability" section: enable AI, or disable for
  employees only (owners/admins keep access); read-only for non-admins
- sidebar, command palette and route guard hide/redirect the AI chat
  when it's disabled for the current user

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-15 18:42:50 +03:00
parent 6fdb7bdb46
commit addddc8972
14 changed files with 3589 additions and 6 deletions
+1
View File
@@ -12,3 +12,4 @@ export * from "./notifications.js";
export * from "./settings.js";
export * from "./ai.js";
export * from "./ai-chat.js";
export * from "./org-ai-policy.js";
+22
View File
@@ -0,0 +1,22 @@
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Clinic-wide AI availability, controlled by owners/admins. One row per clinic
// (organization). Absent row = AI enabled for everyone (the default).
// aiEnabled = false → AI hidden + blocked for the whole clinic.
// disabledForEmployees = true → AI hidden + blocked for non-admins; owners
// and admins keep access.
export const orgAiPolicy = pgTable("org_ai_policy", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
aiEnabled: boolean("ai_enabled").notNull().default(true),
disabledForEmployees: boolean("disabled_for_employees")
.notNull()
.default(false),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+45
View File
@@ -18,10 +18,55 @@ import {
saveAiConfig,
toAiConfig,
} from "../services/ai/config.js";
import { getPolicy, savePolicy } from "../services/ai/policy.js";
import * as patients from "../services/patients.js";
export const aiRouter = Router();
// --- Clinic-wide AI policy (admin-controlled kill-switch) -------------------
// Any member can READ the policy (the frontend needs it to gate nav/routes);
// only owners/admins can change it.
aiRouter.get("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
res.json(await getPolicy(req.organizationId!));
} catch (err) {
next(err);
}
});
aiRouter.put("/policy", requireAuth, requireOrg, async (req, res, next) => {
try {
const roles = String(req.memberRole ?? "")
.split(",")
.map((s) => s.trim());
const isAdmin = roles.some((r) => r === "owner" || r === "admin");
if (!isAdmin) {
throw new HttpError(403, "Only owners and admins can change this.");
}
const body = req.body as {
aiEnabled?: unknown;
disabledForEmployees?: unknown;
};
const saved = await savePolicy(req.organizationId!, {
aiEnabled: Boolean(body.aiEnabled),
disabledForEmployees: Boolean(body.disabledForEmployees),
});
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: saved.aiEnabled
? saved.disabledForEmployees
? "Restricted AI to owners and admins"
: "Enabled the AI assistant clinic-wide"
: "Disabled the AI assistant clinic-wide",
entityType: "patient",
});
res.json(saved);
} catch (err) {
next(err);
}
});
// --- Per-user AI config (no clinic/RBAC needed, like /api/settings) ---------
aiRouter.get("/config", requireAuth, async (req, res, next) => {
try {
+8
View File
@@ -21,6 +21,7 @@ import {
import { recordActivity } from "../services/activity.js";
import * as aiChat from "../services/ai-chat.js";
import { getAiSettings } from "../services/ai/config.js";
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
import { resolveModel } from "../services/ai/provider.js";
import { createChatTools } from "../services/ai/tools.js";
import { createVeil } from "../services/ai/veil.js";
@@ -146,6 +147,13 @@ chatRouter.post("/", async (req, res, next) => {
return;
}
// Honour the clinic's AI kill-switch — employees can't reach the agent even
// by bypassing the (also-gated) UI.
const policy = await getPolicy(req.organizationId!);
if (!aiAllowedFor(policy, req.memberRole)) {
throw new HttpError(403, "The AI assistant is disabled for your account.");
}
const settings = await getAiSettings(req.user!.id);
const modelId = requestedModel || settings.defaultModel;
const resolved = resolveModel(settings, modelId);
+53
View File
@@ -0,0 +1,53 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { orgAiPolicy } from "../../db/schema/org-ai-policy.js";
export type AiPolicy = {
aiEnabled: boolean;
disabledForEmployees: boolean;
};
const DEFAULT_POLICY: AiPolicy = {
aiEnabled: true,
disabledForEmployees: false,
};
// Read a clinic's AI policy, falling back to the permissive default when no row
// exists yet.
export async function getPolicy(orgId: string): Promise<AiPolicy> {
const [row] = await db
.select({
aiEnabled: orgAiPolicy.aiEnabled,
disabledForEmployees: orgAiPolicy.disabledForEmployees,
})
.from(orgAiPolicy)
.where(eq(orgAiPolicy.organizationId, orgId))
.limit(1);
return row ?? DEFAULT_POLICY;
}
export async function savePolicy(
orgId: string,
policy: AiPolicy,
): Promise<AiPolicy> {
await db
.insert(orgAiPolicy)
.values({ organizationId: orgId, ...policy })
.onConflictDoUpdate({
target: orgAiPolicy.organizationId,
set: { ...policy, updatedAt: new Date() },
});
return policy;
}
// Whether a member with `role` may use the AI under `policy`. Owners/admins keep
// access when AI is only disabled for employees; a full disable blocks everyone.
export function aiAllowedFor(policy: AiPolicy, role: string | undefined): boolean {
if (!policy.aiEnabled) return false;
if (!policy.disabledForEmployees) return true;
const names = String(role ?? "")
.split(",")
.map((s) => s.trim());
return names.some((r) => r === "owner" || r === "admin");
}