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
+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");
}