import { z } from "zod"; import { canListOrgMembers, createOrg, listMyOrgInvitations, listOrgInvitations, respondToOrgInvitation, setOrgMember, } from "../accounts"; import { all, first } from "../db"; import { setCollaborator } from "../collaborators"; import { notFoundError } from "../errors"; import { limitWrite, limitAccountWrite } from "../limits"; import { accountNameFormat, displayName } 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: accountNameFormat, display_name: displayName.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, ctx.viewer); return Response.json({ name: data.name }, { status: 201 }); } /** Org profiles are public. */ export async function getOrgApi(_ctx: ApiContext, orgName: string): Promise { const org = await loadOrg(orgName); return Response.json({ id: org.id, login: org.name }); } /** The roster is public unless the org restricts it to members (`members_visibility`). */ export async function listOrgMembers(ctx: ApiContext, orgName: string): Promise { const org = await loadOrg(orgName); if (!(await canListOrgMembers(org.id, ctx.viewer))) throw notFoundError("Organization not found"); 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 }))); } /** * Same rules as the web: owners manage members, anyone may leave, the last owner can't be removed or demoted, * and a non-member is invited (202) rather than added until they accept. */ async function setMember(ctx: ApiContext, orgName: string, username: string, role: "owner" | "member" | "none") { requireAccountScope(ctx); await limitWrite(ctx.viewer); const result = await setOrgMember({ org: orgName, username, role, viewer: ctx.viewer }); if (result.status === "invited") return Response.json({ state: "pending", role }, { status: 202 }); return noContent(); } /** Invitations addressed to the calling user. */ export async function listMyOrgInvitationsApi(ctx: ApiContext): Promise { requireAccountScope(ctx); return Response.json(await listMyOrgInvitations(ctx.viewer)); } /** Pending invitations for an org (owners only). */ export async function listOrgInvitationsApi(ctx: ApiContext, orgName: string): Promise { requireAccountScope(ctx); const rows = await listOrgInvitations(orgName, ctx.viewer); return Response.json(rows.map((row) => ({ login: row.name, role: row.role, expires_at: row.expires_at }))); } export async function acceptOrgInvitationApi(ctx: ApiContext, orgName: string): Promise { requireAccountScope(ctx); await limitWrite(ctx.viewer); await respondToOrgInvitation({ org: orgName, accept: true, viewer: ctx.viewer }); return noContent(); } export async function declineOrgInvitationApi(ctx: ApiContext, orgName: string): Promise { requireAccountScope(ctx); await limitAccountWrite(ctx.viewer); await respondToOrgInvitation({ org: orgName, accept: false, viewer: ctx.viewer }); 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 limitWrite(ctx.viewer); await setCollaborator(repo, username, "none"); return noContent(); }