fix(admin): suspender/reativar sob a trava do aviso do Meet responde 409 retry_later

fn_meet_notice pode lançar appointment_notice_busy (40001) dentro de
fn_org_parada_descarta_fila, e as duas rotas respondiam 500 genérico.
Com error.code '40001' da RPC, respondem 409 `retry_later` com "Outra
operação está em andamento para esta empresa. Tente de novo em
instantes." A transação inteira voltou, então nada foi gravado nem
auditado.

A trava continua sem espera no SQL: esperar ali, com a linha da org em
`for update`, arrisca deadlock.

Código novo em lib/api/errors.ts; um caso por rota no teste unitário.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
melgarafael
2026-09-30 04:28:05 -03:00
co-authored by Claude Opus 5.5
parent 86923ded00
commit 7557a2f44a
5 changed files with 40 additions and 0 deletions
@@ -110,4 +110,15 @@ describe("POST /admin/tenants/[id]/reactivate", () => {
h.rpc.mockResolvedValue({ data: null, error: { message: "boom" } });
expect((await POST(pedido({ reason: MOTIVO }), ctx)).status).toBe(500);
});
it("trava do aviso do Meet (40001 appointment_notice_busy) → 409 retry_later, sem audit", async () => {
h.rpc.mockResolvedValue({ data: null, error: { code: "40001", message: "appointment_notice_busy" } });
const res = await POST(pedido({ reason: MOTIVO }), ctx);
expect(res.status).toBe(409);
expect((await res.json()).error).toMatchObject({
code: "retry_later",
message: "Outra operação está em andamento para esta empresa. Tente de novo em instantes.",
});
expect(h.audit).not.toHaveBeenCalled();
});
});
@@ -29,6 +29,7 @@ const bodySchema = z.object({
.max(500, "Motivo deve ter no máximo 500 caracteres"),
});
const resultadoSchema = z.object({ changed: z.boolean(), motivo: z.string().optional() });
const MENSAGEM_DE_RETENTAR = "Outra operação está em andamento para esta empresa. Tente de novo em instantes.";
const KIND_EXIGIDO: TipoDeSuspensao = "administrativa";
const KIND_DE_COBRANCA: TipoDeSuspensao = "cobranca";
@@ -82,6 +83,12 @@ export async function POST(
p_kind_exigido: KIND_EXIGIDO,
p_ator: adminCtx.user.id,
});
// 40001 = `appointment_notice_busy`: o descarte da fila avisa o Meet com
// trava SEM espera (esperar ali, com a linha da org em `for update`, arrisca
// deadlock). A transação inteira voltou; quem tenta de novo passa.
if (error?.code === "40001") {
return fail("retry_later", MENSAGEM_DE_RETENTAR, 409, { requestId });
}
const resultado = resultadoSchema.safeParse(data);
if (error || !resultado.success) {
return fail("internal_error", "Failed to reactivate tenant", 500, { requestId });
@@ -103,4 +103,15 @@ describe("POST /admin/tenants/[id]/suspend", () => {
h.rpc.mockResolvedValue({ data: null, error: { message: "boom" } });
expect((await POST(pedido({ reason: MOTIVO }), ctx)).status).toBe(500);
});
it("trava do aviso do Meet (40001 appointment_notice_busy) → 409 retry_later, sem audit", async () => {
h.rpc.mockResolvedValue({ data: null, error: { code: "40001", message: "appointment_notice_busy" } });
const res = await POST(pedido({ reason: MOTIVO }), ctx);
expect(res.status).toBe(409);
expect((await res.json()).error).toMatchObject({
code: "retry_later",
message: "Outra operação está em andamento para esta empresa. Tente de novo em instantes.",
});
expect(h.audit).not.toHaveBeenCalled();
});
});
@@ -32,6 +32,7 @@ const bodySchema = z.object({
.max(500, "Motivo deve ter no máximo 500 caracteres"),
});
const resultadoSchema = z.object({ changed: z.boolean(), motivo: z.string().optional() });
const MENSAGEM_DE_RETENTAR = "Outra operação está em andamento para esta empresa. Tente de novo em instantes.";
const KIND: TipoDeSuspensao = "administrativa";
export async function POST(
@@ -75,6 +76,12 @@ export async function POST(
p_motivo: body.reason,
p_ator: adminCtx.user.id,
});
// 40001 = `appointment_notice_busy`: o descarte da fila avisa o Meet com
// trava SEM espera (esperar ali, com a linha da org em `for update`, arrisca
// deadlock). A transação inteira voltou; quem tenta de novo passa.
if (error?.code === "40001") {
return fail("retry_later", MENSAGEM_DE_RETENTAR, 409, { requestId });
}
const resultado = resultadoSchema.safeParse(data);
if (error || !resultado.success) {
return fail("internal_error", "Failed to suspend tenant", 500, { requestId });
+4
View File
@@ -78,6 +78,10 @@ export const ApiErrorCodes = {
// POST /admin/tenants/[id]/reactivate sobre suspensão por falta de pagamento:
// a saída é "Dar prazo" ou "Tornar isenta", nunca o "Reativar" genérico.
suspensao_de_cobranca: "suspensao_de_cobranca",
// POST /admin/tenants/[id]/suspend|reactivate quando o descarte da fila bate
// na trava do aviso do Meet (`appointment_notice_busy`, 40001): outra escrita
// do mesmo contato está em curso. Nada foi gravado; tentar de novo resolve.
retry_later: "retry_later",
invalid_state: "invalid_state", // resposta a um agent_case que saiu de awaiting_human (spec 15 §7)
tenant_already_exists: "tenant_already_exists",
// POST /api/v1/settings/api-tokens quando a organização já está no teto de