mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5.5
parent
86923ded00
commit
7557a2f44a
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user