import "server-only"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { admin, required, user, rawUser } from "./supabase"; import { browserAccount } from "./request-security"; import { ServiceError, checkServiceError } from "./service-error"; import { canReviewMaps, isAdministrator } from "@/shared/permissions"; import { accountActionInput, accountAppealInput, accountAppealReviewInput, } from "@/shared/account-controls"; import { discordAfterResponse } from "./discord-announcements"; type Context = { jsonBody: (r: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; }; const columns = "id,display_name,role,account_status,account_reason,account_changed_at,account_status_version" as const; const respond = (data: unknown, status = 200) => NextResponse.json(data, { status, headers: { "Cache-Control": "private, no-store" }, }); function accountError(error: { code?: string; message: string } | null) { if (error && ["42501", "22023", "P0002"].includes(error.code ?? "")) throw new ServiceError( error.message, error.code === "42501" ? 403 : error.code === "P0002" ? 404 : 400, ); checkServiceError(error); } export async function accountControlsApi( request: NextRequest, path: string[], { jsonBody, limit }: Context, ) { const db = admin(); if (path[0] === "ban" && path[1] === "appeal" && path.length === 2) { // A restricted identity may access only its own status and appeal history. const account = await browserAccount(request, rawUser); const profile = required( await db.from("profiles").select(columns).eq("id", account.id).single(), ); if (request.method === "GET") { const appeals = required( await db .from("account_appeals") .select( "id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at", ) .eq("owner_id", account.id) .order("created_at", { ascending: false }) .limit(50), ); return respond({ account: { ...profile, status_version: profile.account_status_version }, appeals: appeals.map((a) => ({ ...a, display_name: profile.display_name, })), }); } if (request.method === "POST") { const body = accountAppealInput.parse(await jsonBody(request)); await limit("account-appeal:" + account.id, 5, 3600); const submission = await db.rpc("submit_account_appeal", { p_owner: account.id, p_reason: body.reason, }); accountError(submission.error); const id = required(submission); return respond({ id }, 201); } return respond({ error: "Method not allowed" }, 405); } const reviewer = await browserAccount(request, user); const staff = required( await db .from("profiles") .select("role,account_status") .eq("id", reviewer.id) .single(), ); if (!canReviewMaps(staff.role) || staff.account_status !== "active") throw new ServiceError("Moderator permission required", 403); if (path[1] === "accounts") { if (!isAdministrator(staff.role)) throw new ServiceError("Admin permission required", 403); if (path.length === 2 && request.method === "GET") { const search = z .string() .trim() .max(80) .parse(request.nextUrl.searchParams.get("q") ?? ""); let query = db .from("profiles") .select(columns) .order("display_name") .order("id") .limit(30); if (search) query = z.string().uuid().safeParse(search).success ? query.eq("id", search) : query.ilike( "display_name", "%" + search.replace(/[\\%_]/g, "\\$&") + "%", ); return respond({ users: required(await query).map((p) => ({ ...p, status_version: p.account_status_version, })), viewerId: reviewer.id, }); } if (path.length === 3 && request.method === "POST") { const target = z.string().uuid().parse(path[2]); const body = accountActionInput.parse(await jsonBody(request)); if (body.action === "delete" && body.confirmation !== target) throw new ServiceError( "Type the account ID to confirm permanent deletion", 400, ); await limit("account-actions:" + reviewer.id, 30, 3600); const action = await db.rpc("admin_account_action", { p_reviewer: reviewer.id, p_user: target, p_action: body.action === "delete" ? "prepare_delete" : body.action, p_reason: body.reason, }); accountError(action.error); const result = required(action); // Restriction and credential invalidation commit before Auth deletion. A // failed delete leaves a blocked account that an Admin can retry safely. if (body.action === "delete") { const { error } = await db.auth.admin.deleteUser(target, false); if (error) throw new ServiceError( "Account is blocked, but deletion could not finish. Retry permanent deletion.", 503, ); const remaining = await db .from("profiles") .select("id") .eq("id", target) .maybeSingle(); if (remaining.error || remaining.data) throw new ServiceError( "Deletion could not be confirmed. Refresh the account list before retrying.", 503, ); } discordAfterResponse(); return respond({ ok: true, result }); } } if (path[1] === "account-appeals") { if (path.length === 2 && request.method === "GET") { const appeals = required( await db .from("account_appeals") .select( "id,owner_id,status_version,reason,state,created_at,review_reason,reviewed_at", ) .eq("state", "open") .order("created_at") .limit(100), ); const profiles = appeals.length ? required( await db .from("profiles") .select("id,display_name,account_reason") .in("id", [...new Set(appeals.map((a) => a.owner_id))]), ) : []; const names = new Map(profiles.map((p) => [p.id, p.display_name])); const reasons = new Map(profiles.map((p) => [p.id, p.account_reason])); return respond({ appeals: appeals.map((a) => ({ ...a, display_name: names.get(a.owner_id) ?? "Deleted player", ban_reason: reasons.get(a.owner_id) ?? null, })), }); } if (path.length === 3 && request.method === "POST") { const id = z.string().uuid().parse(path[2]); const body = accountAppealReviewInput.parse(await jsonBody(request)); await limit("account-appeal-review:" + reviewer.id, 60, 3600); const review = await db.rpc("review_account_appeal", { p_reviewer: reviewer.id, p_appeal: id, p_decision: body.decision, p_reason: body.reason, }); accountError(review.error); return respond({ ok: true }); } } return respond({ error: "Not found" }, 404); }