Files
curriculum-project-hub/hub/src/admin/routes/explorerRoutes.ts
T
ChickenPige0n 0726dc13c8 feat(admin): restore org Agent role/skill management and fix 404 after project create
- explorer POST /projects now returns {id,name} matching the SPA contract
  (previously returned ProjectOnboardingResult.projectId, so res.id was
  undefined and the redirect to /projects/undefined 404'd)
- add OrganizationAgentConfiguration.listRoles/listSkills + AgentRoleRow/
  AgentSkillRow exports; upsertRole now returns the full row
- new agentConfigRoutes: GET/PUT /agent-roles, PUT /agent-roles/:id/skills,
  GET /agent-skills, GET /agent-models (env-default picker)
- restore admin-web roles page + RoleCard rewired to ADR-0017/0018 backend
  (label, defaultModel, tools whitelist, skill binding, systemPrompt,
  sortOrder, default toggle); add 角色 nav item + roles icon
- skill installation stays out-of-band (CLI/seed) per spec; the surface only
  lists installed skills and binds them to roles
2026-07-15 18:19:47 +08:00

264 lines
9.2 KiB
TypeScript

/**
* Org explorer HTTP routes under `/api/org/:orgSlug`.
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
archiveFolder,
archiveOrgProjectChatBinding,
archiveProject,
createOrgFolder,
createOrgProject,
getOrgProjectDetail,
listMyProjects,
listOrgExplorer,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../org/explorer.js";
import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import { createPermissionAuthorizer } from "../../permissions/authorizer.js";
export interface ExplorerRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
}
export async function registerExplorerRoutes(
app: FastifyInstance,
config: ExplorerRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
app.get("/api/org/:orgSlug/explorer", 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 listOrgExplorer(config.prisma, auth.organization.id);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/my-projects", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, {
orgSlug,
roles: ["OWNER", "ADMIN", "MEMBER"],
});
if (auth === null) return;
const projects = await listMyProjects(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.feishuOpenId,
});
return { projects };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders", 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 {
name?: unknown;
parentId?: unknown;
sortKey?: unknown;
};
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await createOrgFolder(config.prisma, {
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
});
return reply.status(201).send({
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as {
name?: unknown;
sortKey?: unknown;
parentId?: unknown;
};
const folder = await renameFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return {
id: folder.id,
name: folder.name,
parentId: folder.parentId,
sortKey: folder.sortKey,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/folders/:folderId/archive", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveFolder(config.prisma, {
organizationId: auth.organization.id,
folderId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects", 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 { name?: unknown; folderId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const result = await createOrgProject(config.prisma, {
organizationId: auth.organization.id,
actorFeishuOpenId: auth.feishuOpenId,
name: body.name,
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send({ id: result.projectId, name: body.name });
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireProjectPermission(request, reply, guardDeps, {
orgSlug,
projectId,
action: "project.read",
allowOrgAdminOversight: true,
});
if (auth === null) return;
const detail = await getOrgProjectDetail(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
const manageDecision = await createPermissionAuthorizer(config.prisma).can({
actor: { feishuOpenId: auth.feishuOpenId },
action: "collaborator.manage",
resource: { type: "PROJECT", id: projectId },
});
return {
...detail,
actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole),
actorCanManageProject: manageDecision.allowed,
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
return await renameProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/projects/:projectId/folder", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be string or null" },
});
}
return await moveOrgProjectToFolder(config.prisma, {
organizationId: auth.organization.id,
projectId,
folderId: body.folderId as string | null,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveProject(config.prisma, {
organizationId: auth.organization.id,
projectId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/projects/:projectId/binding/archive", async (request, reply) => {
try {
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveOrgProjectChatBinding(config.prisma, {
organizationId: auth.organization.id,
projectId,
actorFeishuOpenId: auth.feishuOpenId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}