Files
curriculum-project-hub/hub/src/admin/routes/teamsRoutes.ts
T
hongjr03 4ad0259193 fix(admin): let project MANAGE holders list teams for grants
GET /teams was org-admin only while team-access mutations require project
MANAGE, so members with MANAGE saw an empty grant picker. Open the
read-only team list to any org member and load it in the project page
whenever the actor can manage the project.
2026-07-14 23:55:16 +08:00

147 lines
5.3 KiB
TypeScript

import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import {
addTeamMember,
archiveTeam,
createTeam,
listOrgTeams,
listTeamMembers,
revokeTeamMember,
updateTeam,
} from "../../org/teams.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
export async function registerTeamsRoutes(
app: FastifyInstance,
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
// Read-only listing is open to any active org member so project MANAGE
// holders can pick a team when granting TEAM→PROJECT access (ADR-0004).
// Mutations below stay org-admin only.
app.get("/api/org/:orgSlug/teams", 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;
return { teams: await listOrgTeams(config.prisma, auth.organization.id) };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams", 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 { slug?: unknown; name?: unknown; description?: unknown };
if (typeof body.slug !== "string" || typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "slug and name are required" },
});
}
const team = await createTeam(config.prisma, {
organizationId: auth.organization.id,
slug: body.slug,
name: body.name,
...(typeof body.description === "string" ? { description: body.description } : {}),
});
return reply.status(201).send(team);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/teams/:teamId", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; description?: unknown };
return await updateTeam(config.prisma, {
organizationId: auth.organization.id,
teamId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.description === null || typeof body.description === "string"
? { description: body.description as string | null }
: {}),
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/archive", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await archiveTeam(config.prisma, {
organizationId: auth.organization.id,
teamId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
app.get("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return {
members: await listTeamMembers(config.prisma, {
organizationId: auth.organization.id,
teamId,
}),
};
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/members", async (request, reply) => {
try {
const { orgSlug, teamId } = request.params as { orgSlug: string; teamId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { userId?: unknown; feishuOpenId?: unknown };
const member = await addTeamMember(config.prisma, {
organizationId: auth.organization.id,
teamId,
...(typeof body.userId === "string" ? { userId: body.userId } : {}),
...(typeof body.feishuOpenId === "string" ? { feishuOpenId: body.feishuOpenId } : {}),
});
return reply.status(201).send(member);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/teams/:teamId/members/:userId/revoke", async (request, reply) => {
try {
const { orgSlug, teamId, userId } = request.params as {
orgSlug: string;
teamId: string;
userId: string;
};
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return await revokeTeamMember(config.prisma, {
organizationId: auth.organization.id,
teamId,
userId,
});
} catch (err) {
return handleRouteError(reply, err);
}
});
}