forked from bai/curriculum-project-hub
feat: add org admin SPA for models, roles and provider
Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Org-admin routes for agent config: provider connection, enabled models, and
|
||||
* role presets (ADR-0017 / ADR-0021). Mounted under `/api/org/:orgSlug`.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
createOrgModel,
|
||||
createOrgRole,
|
||||
deleteOrgModel,
|
||||
deleteOrgRole,
|
||||
getProviderConnection,
|
||||
listOrgModels,
|
||||
listOrgRoles,
|
||||
updateOrgModel,
|
||||
updateOrgRole,
|
||||
upsertProviderConnection,
|
||||
} from "../../org/agentConfig.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
const PROVIDER_MODES = ["BYOK", "PLATFORM_MANAGED"] as const;
|
||||
type ProviderMode = (typeof PROVIDER_MODES)[number];
|
||||
|
||||
export async function registerModelRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
// --- Provider connection ---------------------------------------------
|
||||
|
||||
app.get("/api/org/:orgSlug/provider-connection", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await getProviderConnection(config.prisma, auth.organization.id);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/provider-connection", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
providerId?: unknown;
|
||||
mode?: unknown;
|
||||
baseUrl?: unknown;
|
||||
authToken?: unknown;
|
||||
};
|
||||
const mode = parseProviderMode(body.mode);
|
||||
if (mode === null) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "mode must be BYOK or PLATFORM_MANAGED" },
|
||||
});
|
||||
}
|
||||
const connection = await upsertProviderConnection(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
mode,
|
||||
...(typeof body.providerId === "string" ? { providerId: body.providerId } : {}),
|
||||
...(body.baseUrl === null || typeof body.baseUrl === "string"
|
||||
? { baseUrl: body.baseUrl as string | null }
|
||||
: {}),
|
||||
...(body.authToken === null || typeof body.authToken === "string"
|
||||
? { authToken: body.authToken as string | null }
|
||||
: {}),
|
||||
});
|
||||
return connection;
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Models ----------------------------------------------------------
|
||||
|
||||
app.get("/api/org/:orgSlug/models", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { models: await listOrgModels(config.prisma, auth.organization.id) };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/models", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
modelId?: unknown;
|
||||
label?: unknown;
|
||||
toolCapable?: unknown;
|
||||
sortKey?: unknown;
|
||||
};
|
||||
if (typeof body.modelId !== "string" || typeof body.label !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "modelId and label are required" },
|
||||
});
|
||||
}
|
||||
const model = await createOrgModel(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
modelId: body.modelId,
|
||||
label: body.label,
|
||||
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
|
||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
||||
});
|
||||
return reply.status(201).send(model);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
label?: unknown;
|
||||
toolCapable?: unknown;
|
||||
sortKey?: unknown;
|
||||
modelId?: unknown;
|
||||
};
|
||||
return await updateOrgModel(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
id: modelId,
|
||||
...(typeof body.label === "string" ? { label: body.label } : {}),
|
||||
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
|
||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
||||
...(typeof body.modelId === "string" ? { modelId: body.modelId } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await deleteOrgModel(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
id: modelId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Roles -----------------------------------------------------------
|
||||
|
||||
app.get("/api/org/:orgSlug/roles", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return { roles: await listOrgRoles(config.prisma, auth.organization.id) };
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/org/:orgSlug/roles", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
roleId?: unknown;
|
||||
label?: unknown;
|
||||
defaultModelId?: unknown;
|
||||
systemPrompt?: unknown;
|
||||
tools?: unknown;
|
||||
};
|
||||
if (typeof body.roleId !== "string" || typeof body.label !== "string") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "roleId and label are required" },
|
||||
});
|
||||
}
|
||||
const role = await createOrgRole(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
roleId: body.roleId,
|
||||
label: body.label,
|
||||
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
|
||||
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
|
||||
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
|
||||
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
|
||||
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
|
||||
...(body.tools === null ? { tools: null } : {}),
|
||||
});
|
||||
return reply.status(201).send(role);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as {
|
||||
label?: unknown;
|
||||
defaultModelId?: unknown;
|
||||
systemPrompt?: unknown;
|
||||
tools?: unknown;
|
||||
roleId?: unknown;
|
||||
};
|
||||
return await updateOrgRole(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
id: roleId,
|
||||
...(typeof body.label === "string" ? { label: body.label } : {}),
|
||||
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
|
||||
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
|
||||
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
|
||||
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
|
||||
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
|
||||
...(body.tools === null ? { tools: null } : {}),
|
||||
...(typeof body.roleId === "string" ? { roleId: body.roleId } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/org/:orgSlug/roles/:roleId", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await deleteOrgRole(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
id: roleId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseProviderMode(value: unknown): ProviderMode | null {
|
||||
if (typeof value !== "string") return null;
|
||||
return (PROVIDER_MODES as readonly string[]).includes(value) ? (value as ProviderMode) : null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { registerAccessRoutes } from "./accessRoutes.js";
|
||||
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
||||
import { registerMembersRoutes } from "./membersRoutes.js";
|
||||
import { registerModelRoutes } from "./modelRoutes.js";
|
||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerModelRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerSessionsAndUsageRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and
|
||||
* the SPA index fallback for client-side routes under `/admin/*`.
|
||||
*
|
||||
* The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there
|
||||
* to produce the static output in `admin-web/build/`. In development, use
|
||||
* `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub.
|
||||
*
|
||||
* Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, extname, join, resolve as resolvePath } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
function resolveUiDir(): string {
|
||||
const override = process.env["CPH_ADMIN_UI_DIR"];
|
||||
if (override && override.trim() !== "") return resolvePath(override);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolvePath(join(here, "..", "..", "admin-web", "build"));
|
||||
}
|
||||
|
||||
export async function registerStaticSpa(app: FastifyInstance): Promise<void> {
|
||||
const uiDir = resolveUiDir();
|
||||
if (!existsSync(join(uiDir, "index.html"))) {
|
||||
app.log.warn(
|
||||
{ uiDir },
|
||||
"admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
|
||||
|
||||
// SvelteKit static assets (_app/*, favicon, etc.)
|
||||
app.get("/_app/*", async (request, reply) => {
|
||||
const rel = (request.params as { "*": string })["*"];
|
||||
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "_app", safe));
|
||||
const mime = MIME[extname(safe)] ?? "application/octet-stream";
|
||||
return reply.type(mime).send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
|
||||
}
|
||||
});
|
||||
|
||||
// Other top-level static assets (favicon.svg, robots.txt, etc.)
|
||||
app.get("/favicon.svg", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "favicon.svg"));
|
||||
return reply.type("image/svg+xml").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
|
||||
// SPA client-side route fallback. `/admin/login` is registered earlier as a
|
||||
// static route and takes precedence; everything else under /admin/* serves
|
||||
// the index so SvelteKit's client-side router can resolve the view.
|
||||
app.get("/admin", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
app.get("/admin/*", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Org-scoped agent config: provider connection, enabled models, and role
|
||||
* presets (ADR-0017 / ADR-0021).
|
||||
*
|
||||
* Roles and models are **data** (ADR-0017): admin/teacher-defined, persisted
|
||||
* per organization. The runtime registry (`settings/runtime.ts`) reads these
|
||||
* rows, falling back to env-based defaults when an org has none configured.
|
||||
*
|
||||
* Provider credential storage is a pilot placeholder: the spec leaves key/base
|
||||
* URL encryption, rotation and resolver mechanism OPEN (secret-control-plane).
|
||||
* BYOK tokens are stored as opaque strings here; platform-managed credentials
|
||||
* are resolved from process env, not stored in this table.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { assertSupportedRoleTools } from "../agent/roleTools.js";
|
||||
|
||||
export interface ProviderConnectionRow {
|
||||
readonly organizationId: string;
|
||||
readonly providerId: string;
|
||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
||||
readonly baseUrl: string | null;
|
||||
readonly hasAuthToken: boolean;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export async function getProviderConnection(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<ProviderConnectionRow> {
|
||||
const row = await prisma.organizationProviderConnection.findUnique({
|
||||
where: { organizationId },
|
||||
});
|
||||
if (row === null) {
|
||||
return {
|
||||
organizationId,
|
||||
providerId: "openrouter",
|
||||
mode: "PLATFORM_MANAGED",
|
||||
baseUrl: null,
|
||||
hasAuthToken: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
providerId: row.providerId,
|
||||
mode: row.mode,
|
||||
baseUrl: row.baseUrl,
|
||||
hasAuthToken: row.authToken !== null && row.authToken !== "",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertProviderConnection(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly providerId?: string | undefined;
|
||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
||||
readonly baseUrl?: string | null | undefined;
|
||||
readonly authToken?: string | null | undefined;
|
||||
},
|
||||
): Promise<ProviderConnectionRow> {
|
||||
const providerId = input.providerId ?? "openrouter";
|
||||
if (providerId.trim() === "") throw new Error("providerId is required");
|
||||
|
||||
const baseUrl =
|
||||
input.baseUrl === undefined || input.baseUrl === null
|
||||
? null
|
||||
: input.baseUrl.trim() === ""
|
||||
? null
|
||||
: input.baseUrl.trim();
|
||||
|
||||
const authToken =
|
||||
input.authToken === undefined || input.authToken === null
|
||||
? undefined
|
||||
: input.authToken === ""
|
||||
? null
|
||||
: input.authToken;
|
||||
|
||||
const row = await prisma.organizationProviderConnection.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
providerId,
|
||||
mode: input.mode,
|
||||
baseUrl,
|
||||
...(authToken !== undefined ? { authToken } : {}),
|
||||
},
|
||||
update: {
|
||||
providerId,
|
||||
mode: input.mode,
|
||||
baseUrl,
|
||||
...(authToken !== undefined ? { authToken } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
providerId: row.providerId,
|
||||
mode: row.mode,
|
||||
baseUrl: row.baseUrl,
|
||||
hasAuthToken: row.authToken !== null && row.authToken !== "",
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrgModelRow {
|
||||
readonly id: string;
|
||||
readonly modelId: string;
|
||||
readonly label: string;
|
||||
readonly toolCapable: boolean;
|
||||
readonly sortKey: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export async function listOrgModels(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<readonly OrgModelRow[]> {
|
||||
const rows = await prisma.orgModel.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ sortKey: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return rows.map(toModelRow);
|
||||
}
|
||||
|
||||
export async function createOrgModel(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly modelId: string;
|
||||
readonly label: string;
|
||||
readonly toolCapable?: boolean | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
},
|
||||
): Promise<OrgModelRow> {
|
||||
const modelId = requireNonEmpty(input.modelId, "modelId");
|
||||
const label = requireNonEmpty(input.label, "label");
|
||||
const existing = await prisma.orgModel.findUnique({
|
||||
where: { organizationId_modelId: { organizationId: input.organizationId, modelId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new Error(`model already exists: ${modelId}`);
|
||||
}
|
||||
const row = await prisma.orgModel.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
modelId,
|
||||
label,
|
||||
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
|
||||
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
|
||||
},
|
||||
});
|
||||
return toModelRow(row);
|
||||
}
|
||||
|
||||
export async function updateOrgModel(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly id: string;
|
||||
readonly label?: string | undefined;
|
||||
readonly toolCapable?: boolean | undefined;
|
||||
readonly sortKey?: string | undefined;
|
||||
readonly modelId?: string | undefined;
|
||||
},
|
||||
): Promise<OrgModelRow> {
|
||||
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
|
||||
if (row === null || row.organizationId !== input.organizationId) {
|
||||
throw new Error(`model not found: ${input.id}`);
|
||||
}
|
||||
const updated = await prisma.orgModel.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
|
||||
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
|
||||
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
|
||||
...(input.modelId !== undefined
|
||||
? { modelId: requireNonEmpty(input.modelId, "modelId") }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return toModelRow(updated);
|
||||
}
|
||||
|
||||
export async function deleteOrgModel(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly id: string },
|
||||
): Promise<{ readonly deleted: true; readonly id: string }> {
|
||||
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
|
||||
if (row === null || row.organizationId !== input.organizationId) {
|
||||
throw new Error(`model not found: ${input.id}`);
|
||||
}
|
||||
await prisma.orgModel.delete({ where: { id: row.id } });
|
||||
return { deleted: true as const, id: row.id };
|
||||
}
|
||||
|
||||
export interface OrgRoleRow {
|
||||
readonly id: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModelId: string | null;
|
||||
readonly systemPrompt: string | null;
|
||||
readonly tools: readonly string[] | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export async function listOrgRoles(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<readonly OrgRoleRow[]> {
|
||||
const rows = await prisma.orgRole.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: [{ roleId: "asc" }],
|
||||
});
|
||||
return rows.map(toRoleRow);
|
||||
}
|
||||
|
||||
export async function createOrgRole(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModelId?: string | null | undefined;
|
||||
readonly systemPrompt?: string | null | undefined;
|
||||
readonly tools?: readonly string[] | null | undefined;
|
||||
},
|
||||
): Promise<OrgRoleRow> {
|
||||
const roleId = requireNonEmpty(input.roleId, "roleId");
|
||||
const label = requireNonEmpty(input.label, "label");
|
||||
const existing = await prisma.orgRole.findUnique({
|
||||
where: { organizationId_roleId: { organizationId: input.organizationId, roleId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing !== null) {
|
||||
throw new Error(`role already exists: ${roleId}`);
|
||||
}
|
||||
const tools = normalizeTools(input.tools);
|
||||
const row = await prisma.orgRole.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
roleId,
|
||||
label,
|
||||
...(input.defaultModelId !== undefined && input.defaultModelId !== null
|
||||
? { defaultModelId: input.defaultModelId }
|
||||
: {}),
|
||||
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
|
||||
...(input.systemPrompt !== undefined && input.systemPrompt !== null
|
||||
? { systemPrompt: input.systemPrompt }
|
||||
: {}),
|
||||
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
|
||||
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
|
||||
},
|
||||
});
|
||||
return toRoleRow(row);
|
||||
}
|
||||
|
||||
export async function updateOrgRole(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly id: string;
|
||||
readonly label?: string | undefined;
|
||||
readonly defaultModelId?: string | null | undefined;
|
||||
readonly systemPrompt?: string | null | undefined;
|
||||
readonly tools?: readonly string[] | null | undefined;
|
||||
readonly roleId?: string | undefined;
|
||||
},
|
||||
): Promise<OrgRoleRow> {
|
||||
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
|
||||
if (row === null || row.organizationId !== input.organizationId) {
|
||||
throw new Error(`role not found: ${input.id}`);
|
||||
}
|
||||
const tools = normalizeTools(input.tools);
|
||||
const updated = await prisma.orgRole.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
|
||||
...(input.defaultModelId !== undefined && input.defaultModelId !== null
|
||||
? { defaultModelId: input.defaultModelId }
|
||||
: {}),
|
||||
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
|
||||
...(input.systemPrompt !== undefined && input.systemPrompt !== null
|
||||
? { systemPrompt: input.systemPrompt }
|
||||
: {}),
|
||||
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
|
||||
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
|
||||
...(input.roleId !== undefined ? { roleId: requireNonEmpty(input.roleId, "roleId") } : {}),
|
||||
},
|
||||
});
|
||||
return toRoleRow(updated);
|
||||
}
|
||||
|
||||
export async function deleteOrgRole(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly id: string },
|
||||
): Promise<{ readonly deleted: true; readonly id: string }> {
|
||||
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
|
||||
if (row === null || row.organizationId !== input.organizationId) {
|
||||
throw new Error(`role not found: ${input.id}`);
|
||||
}
|
||||
await prisma.orgRole.delete({ where: { id: row.id } });
|
||||
return { deleted: true as const, id: row.id };
|
||||
}
|
||||
|
||||
function toModelRow(
|
||||
row: {
|
||||
readonly id: string;
|
||||
readonly modelId: string;
|
||||
readonly label: string;
|
||||
readonly toolCapable: boolean;
|
||||
readonly sortKey: string;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
): OrgModelRow {
|
||||
return {
|
||||
id: row.id,
|
||||
modelId: row.modelId,
|
||||
label: row.label,
|
||||
toolCapable: row.toolCapable,
|
||||
sortKey: row.sortKey,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toRoleRow(
|
||||
row: {
|
||||
readonly id: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModelId: string | null;
|
||||
readonly systemPrompt: string | null;
|
||||
readonly tools: unknown;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
): OrgRoleRow {
|
||||
return {
|
||||
id: row.id,
|
||||
roleId: row.roleId,
|
||||
label: row.label,
|
||||
defaultModelId: row.defaultModelId,
|
||||
systemPrompt: row.systemPrompt,
|
||||
tools: parseTools(row.tools),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a tools input into a value safe for prisma `tools` (Json?) column.
|
||||
* - `undefined` ⇒ omit (leave unchanged): returns `undefined`.
|
||||
* - `null` ⇒ unrestricted role (SQL NULL): returns `null`.
|
||||
* - array ⇒ validated against the supported role tool set (fails fast).
|
||||
*/
|
||||
function normalizeTools(
|
||||
tools: readonly string[] | null | undefined,
|
||||
): readonly string[] | null | undefined {
|
||||
if (tools === undefined) return undefined;
|
||||
if (tools === null) return null;
|
||||
const list = [...tools];
|
||||
assertSupportedRoleTools(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function parseTools(tools: unknown): readonly string[] | null {
|
||||
if (tools === null) return null;
|
||||
if (!Array.isArray(tools)) return null;
|
||||
return tools.filter((t): t is string => typeof t === "string");
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, label: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") throw new Error(`${label} is required`);
|
||||
return trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user