diff --git a/packages/console/app/src/routes/api/support/actions/block-workspaces.ts b/packages/console/app/src/routes/api/support/actions/block-workspaces.ts new file mode 100644 index 0000000000..9f1fc0e9a3 --- /dev/null +++ b/packages/console/app/src/routes/api/support/actions/block-workspaces.ts @@ -0,0 +1,22 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" +import { Resource } from "@opencode-ai/console-resource" +import z from "zod" + +const Body = z.object({ workspaceIDs: z.array(z.string().startsWith("wrk_")).min(1) }) + +export async function POST(event: APIEvent) { + if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = Body.safeParse(await event.request.json().catch(() => undefined)) + if (!body.success) { + return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 }) + } + // Missing IDs do not fail the batch; they are reported per item in the result. + return Workspace.blockBatch(body.data) + .then((result) => Response.json({ success: true, message: "Workspaces blocked", result })) + .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })) +} diff --git a/packages/console/app/src/routes/api/support/actions/unblock-workspaces.ts b/packages/console/app/src/routes/api/support/actions/unblock-workspaces.ts new file mode 100644 index 0000000000..59ac1f354e --- /dev/null +++ b/packages/console/app/src/routes/api/support/actions/unblock-workspaces.ts @@ -0,0 +1,22 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import { safeEqual } from "@opencode-ai/console-core/util/crypto.js" +import { Resource } from "@opencode-ai/console-resource" +import z from "zod" + +const Body = z.object({ workspaceIDs: z.array(z.string().startsWith("wrk_")).min(1) }) + +export async function POST(event: APIEvent) { + if (!safeEqual(event.request.headers.get("authorization") ?? "", `Bearer ${Resource.SUPPORT_API_KEY.value}`)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = Body.safeParse(await event.request.json().catch(() => undefined)) + if (!body.success) { + return Response.json({ error: "Invalid request", issues: body.error.issues }, { status: 400 }) + } + // Missing IDs do not fail the batch; they are reported per item in the result. + return Workspace.unblockBatch(body.data) + .then((result) => Response.json({ success: true, message: "Workspaces unblocked", result })) + .catch((error) => Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })) +} diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index 432d4947bd..019cad2009 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -8,7 +8,7 @@ import { BillingTable } from "./schema/billing.sql" import { WorkspaceTable } from "./schema/workspace.sql" import { AccountTable } from "./schema/account.sql" import { Key } from "./key" -import { and, eq, isNull, sql } from "drizzle-orm" +import { and, eq, inArray, isNull, sql } from "drizzle-orm" export namespace Workspace { export const Region = z.enum(["us", "eu", "sg", "cn"]) @@ -109,6 +109,26 @@ export namespace Workspace { }, ) + export const blockBatch = fn( + z.object({ + workspaceIDs: z.array(Identifier.schema("workspace")).min(1), + }), + async (input) => { + const { applied, notFound } = await setBlockedBatch(input, true) + return { blocked: applied, notFound } + }, + ) + + export const unblockBatch = fn( + z.object({ + workspaceIDs: z.array(Identifier.schema("workspace")).min(1), + }), + async (input) => { + const { applied, notFound } = await setBlockedBatch(input, false) + return { unblocked: applied, notFound } + }, + ) + export const remove = fn(z.void(), async () => { await Database.use((tx) => tx @@ -118,3 +138,24 @@ export namespace Workspace { ) }) } + +// No size cap by design; large IN lists degrade on Vitess, so chunks stay fixed-size and +// sequential to share one connection inside ambient transactions. Existence comes from the +// follow-up select rather than rowsAffected, so no-op rows (already in the target state) +// still report as applied instead of "not found". +const setBlockedBatch = async (input: { workspaceIDs: string[] }, isBlocked: boolean) => { + const ids = [...new Set(input.workspaceIDs)] + return await Database.use(async (tx) => { + const applied = new Set() + for (let index = 0; index < ids.length; index += 500) { + const chunk = ids.slice(index, index + 500) + await tx.update(WorkspaceTable).set({ is_blocked: isBlocked }).where(inArray(WorkspaceTable.id, chunk)) + const rows = await tx.select({ id: WorkspaceTable.id }).from(WorkspaceTable).where(inArray(WorkspaceTable.id, chunk)) + for (const row of rows) applied.add(row.id) + } + return { + applied: ids.filter((id) => applied.has(id)), + notFound: ids.filter((id) => !applied.has(id)), + } + }) +}