forked from EduCraft/curriculum-project-hub
0726dc13c8
- 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
264 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|