import { z } from "zod"; import { all, first, run } from "../db"; import { createOrg } from "../accounts"; import { findCollaboratorUser, setCollaborator } from "../collaborators"; import { badRequest, forbidden, notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { canAdministerAccount } from "../repos"; import { accountName } from "~/lib/validation"; import { noContent, readJson, requireAccountScope, requireScopedRepo } from "./http"; import type { ApiContext } from "./http"; async function loadOrg(name: string): Promise<{ id: string; name: string }> { const org = await first<{ id: string; name: string }>( "SELECT id, name FROM accounts WHERE name = ? AND kind = 'org'", name, ); if (!org) throw notFoundError("Organization not found"); return org; } const orgInput = z.object({ name: accountName, display_name: z.string().max(100).nullable().default(null) }); /** Mirrors fn:account.createOrganization. */ export async function createOrgApi(ctx: ApiContext): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, orgInput); await limitWrite(ctx.viewer); await createOrg(data.name, data.display_name, ctx.viewer.id); return Response.json({ name: data.name }, { status: 201 }); } /** Membership is public on the org profile page, so any token may read it. */ export async function listOrgMembers(_ctx: ApiContext, orgName: string): Promise { const org = await loadOrg(orgName); const members = await all<{ name: string; role: string }>( "SELECT a.name, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.name", org.id, ); return Response.json(members.map((member) => ({ login: member.name, role: member.role }))); } /** Mirrors fn:account.setOrgMember, which keeps its mutation inline and cannot be called without a session. */ async function setMember(ctx: ApiContext, orgName: string, username: string, role: "owner" | "member" | "none") { requireAccountScope(ctx); const org = await loadOrg(orgName); if (!(await canAdministerAccount(org.id, ctx.viewer))) throw forbidden(); const user = await findCollaboratorUser(username); if (role === "none") { const owners = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?", org.id, user.id, ); if (!owners?.count) throw badRequest("An organization needs at least one owner"); await run("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", org.id, user.id); } else { await run( `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`, org.id, user.id, role, Date.now(), ); } return noContent(); } const memberInput = z.object({ role: z.enum(["owner", "member"]) }); export async function putOrgMember(ctx: ApiContext, orgName: string, username: string): Promise { const data = await readJson(ctx.request, memberInput); return setMember(ctx, orgName, username, data.role); } export function deleteOrgMember(ctx: ApiContext, orgName: string, username: string): Promise { return setMember(ctx, orgName, username, "none"); } export async function deleteCollaborator(ctx: ApiContext, owner: string, name: string, username: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await setCollaborator(repo, username, "none"); return noContent(); }