fix: bug sistêmico no cliente HTTP quebrava toda ação sem payload

Causa raiz do 'body is empty' relatado pelo usuário ao clicar em
redefinir senha do ramal: o cliente sempre mandava Content-Type:
application/json mesmo em requisições sem corpo (POST/PATCH de ação, ex.:
reset-password, agent-console available/pause/unpause/logout, dialplan
publish/rollback, campanha start/pause/stop/drain). O Fastify rejeita
isso com 400 'Body cannot be empty when content-type is set to
application/json' — nunca detectado antes porque todo teste anterior foi
via curl sem fixar esse header, não reproduzindo o comportamento real do
fetch() do navegador. Corrigido na única função request() central
(apps/frontend/src/lib/api-client.ts): só envia o header quando há de
fato um corpo.

Bug relacionado encontrado na mesma revisão: DELETE /api/suppression/:id
exige removalReason no corpo, mas api.delete() nem aceitava um argumento
de corpo — o botão 'Desbloquear' da lista de bloqueio sempre falhava com
403. api.delete() agora aceita body opcional; a tela pede o motivo via
prompt antes de remover.
This commit is contained in:
2026-08-27 20:48:00 -03:00
parent 71555af5da
commit fa8339003d
3 changed files with 16 additions and 5 deletions

View File

@@ -95,7 +95,8 @@ function SuppressionContent() {
}); });
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: string) => suppressionService.remove(id), mutationFn: ({ id, removalReason }: { id: string; removalReason: string }) =>
suppressionService.remove(id, removalReason),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['suppression'] }); queryClient.invalidateQueries({ queryKey: ['suppression'] });
toast({ title: 'Número desbloqueado', variant: 'success' }); toast({ title: 'Número desbloqueado', variant: 'success' });
@@ -131,7 +132,12 @@ function SuppressionContent() {
variant="ghost" variant="ghost"
className="text-destructive" className="text-destructive"
onClick={() => { onClick={() => {
if (confirm(`Desbloquear ${r.phoneNormalized}?`)) remove.mutate(r.id); const removalReason = window.prompt(
`Motivo para desbloquear ${r.phoneNormalized} (obrigatório):`,
);
if (removalReason && removalReason.trim().length > 0) {
remove.mutate({ id: r.id, removalReason: removalReason.trim() });
}
}} }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />

View File

@@ -61,7 +61,11 @@ async function request<T>(
const init: RequestInit = { const init: RequestInit = {
method, method,
credentials: 'include', credentials: 'include',
headers: isForm ? undefined : { 'Content-Type': 'application/json' }, // Content-Type: application/json sem corpo faz o Fastify rejeitar a
// requisição ("Body cannot be empty when content-type is set to
// 'application/json'") — só envia o header quando há de fato um corpo
// (ex.: POST de ação sem payload, como reset-password/publish/start).
headers: isForm || body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: isForm body: isForm
? (body as FormData) ? (body as FormData)
: body !== undefined : body !== undefined
@@ -119,7 +123,7 @@ export const api = {
request<T>('GET', path, { params }), request<T>('GET', path, { params }),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }), post: <T>(path: string, body?: unknown) => request<T>('POST', path, { body }),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }), patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, { body }),
delete: <T>(path: string) => request<T>('DELETE', path), delete: <T>(path: string, body?: unknown) => request<T>('DELETE', path, { body }),
upload: <T>(path: string, form: FormData, params?: object) => upload: <T>(path: string, form: FormData, params?: object) =>
request<T>('POST', path, { body: form, isForm: true, params }), request<T>('POST', path, { body: form, isForm: true, params }),
download: (path: string, params?: object) => download: (path: string, params?: object) =>

View File

@@ -17,5 +17,6 @@ export const suppressionService = {
form.append('file', file); form.append('file', file);
return api.upload<SuppressionImportResult>('/suppression/import', form); return api.upload<SuppressionImportResult>('/suppression/import', form);
}, },
remove: (id: string) => api.delete<void>(`/suppression/${id}`), remove: (id: string, removalReason: string) =>
api.delete<void>(`/suppression/${id}`, { removalReason }),
}; };