/** * 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 { 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); } }); }