import { all, batch, first, stmt } from "./db"; import { badRequest, conflict, forbidden, notFoundError } from "./errors"; import { canAdministerAccount, loadRepoById } from "./repos"; import { deleteRepository } from "./repo-ops"; import { deleteRunnerRow } from "./ci/runner-registration"; import type { Viewer } from "~/lib/types"; export const MAX_ORG_REPOS_DELETED = 100; /** * Deletes an organization. Owners only, with the org name typed as confirmation. Refuses while repositories * remain unless `deleteRepos`, in which case each goes through `deleteRepository`. The org's current and * previous names are retired so they can never be re-registered (GIT-9); everything keyed to the account * (memberships, runners, registration tokens, secrets, sites, projects, LFS usage) cascades with the row. */ export async function deleteOrganization(input: { org: string; confirm: string; deleteRepos: boolean; viewer: Viewer; }): Promise<{ deletedRepos: string[] }> { const org = await first<{ id: string; name: string }>( "SELECT id, name FROM accounts WHERE name = ? AND kind = 'org'", input.org, ); if (!org) throw notFoundError("Organization not found"); if (!(await canAdministerAccount(org.id, input.viewer))) { const member = await first( "SELECT 1 AS ok FROM org_members WHERE org_id = ? AND user_id = ?", org.id, input.viewer.id, ); throw member ? forbidden("Only organization owners can delete it") : notFoundError("Organization not found"); } if (input.confirm !== org.name) throw badRequest(`Type ${org.name} to confirm`); const repos = await all<{ id: string; name: string }>( "SELECT id, name FROM repos WHERE owner_id = ? ORDER BY name", org.id, ); if (repos.length > 0 && !input.deleteRepos) { throw conflict(`Delete or transfer its repositories first: ${repos.map((repo) => repo.name).join(", ")}`); } if (repos.length > MAX_ORG_REPOS_DELETED) { throw conflict(`Too many repositories to delete at once (${repos.length}); delete some first`); } for (const { id } of repos) { const repo = await loadRepoById(id); if (repo) await deleteRepository(repo, input.viewer); } const runners = await all<{ id: string }>("SELECT id FROM runners WHERE owner_id = ?", org.id); for (const runner of runners) await deleteRunnerRow(runner.id); const now = Date.now(); const previousNames = await all<{ old_name: string }>( "SELECT old_name FROM account_redirects WHERE account_id = ?", org.id, ); await batch([ ...[org.name, ...previousNames.map((row) => row.old_name)].map((name) => stmt("INSERT OR IGNORE INTO retired_account_names (name, retired_at) VALUES (?, ?)", name, now), ), stmt("DELETE FROM integrations WHERE scope_type = 'org' AND scope_id = ?", org.id), stmt("DELETE FROM lfs_account_objects WHERE owner_id = ?", org.id), stmt("DELETE FROM org_members WHERE org_id = ?", org.id), stmt("DELETE FROM runner_registration_tokens WHERE owner_id = ?", org.id), stmt("DELETE FROM sites WHERE owner_id = ?", org.id), stmt("DELETE FROM accounts WHERE id = ?", org.id), ]); return { deletedRepos: repos.map((repo) => repo.name) }; }