From f61c62f9144a80d7544f37a70c26100461aaf801 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:09:55 -0300 Subject: [PATCH] feat: [TESIS-999006] show and operate the failed events queue The retry engine (RF-16) records every failed webhook and outbound request, retries it with exponential backoff and exposes list, retry and discard endpoints, but nothing showed it: the operator could not tell a sale failed to come in, and the resilience acceptance criterion could not be shown. The new Eventos fallidos section lists the queue with tabs per status and their counts, names each event type in words, and shows the attempts, the next automatic retry and the last error with its HTTP status. Each row offers only the actions the API accepts for its status; discarding asks first, and a 422 on retry is explained because the event changed status meanwhile. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 1 + src/app/router/routes.test.tsx | 9 +- src/app/router/routes.tsx | 9 + src/features/failed-events/api.ts | 88 +++++++ src/features/failed-events/content.ts | 107 ++++++++ .../failed-events/hooks/useFailedEvents.ts | 71 +++++ src/features/failed-events/index.ts | 1 + .../pages/FailedEventsPage.test.tsx | 141 ++++++++++ .../failed-events/pages/FailedEventsPage.tsx | 246 ++++++++++++++++++ src/features/failed-events/queryKeys.ts | 11 + src/features/failed-events/types.ts | 42 +++ .../failed-events/utils/actions.test.ts | 51 ++++ src/features/failed-events/utils/actions.ts | 27 ++ 13 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 src/features/failed-events/api.ts create mode 100644 src/features/failed-events/content.ts create mode 100644 src/features/failed-events/hooks/useFailedEvents.ts create mode 100644 src/features/failed-events/index.ts create mode 100644 src/features/failed-events/pages/FailedEventsPage.test.tsx create mode 100644 src/features/failed-events/pages/FailedEventsPage.tsx create mode 100644 src/features/failed-events/queryKeys.ts create mode 100644 src/features/failed-events/types.ts create mode 100644 src/features/failed-events/utils/actions.test.ts create mode 100644 src/features/failed-events/utils/actions.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..4ed2554 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -314,6 +314,7 @@ features/[nombre]/ | `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | | `home` | Landing de la app (`/`) | | `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta | | `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | | `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx index 7438c93..631d751 100644 --- a/src/app/router/routes.test.tsx +++ b/src/app/router/routes.test.tsx @@ -24,7 +24,14 @@ describe('navRoutesFor', () => { }) it('keeps the sections that belong to the product for every tenant', () => { - expect(navPaths({})).toEqual(['/', '/dashboard', '/orders', '/inventory', '/reports']) + expect(navPaths({})).toEqual([ + '/', + '/dashboard', + '/orders', + '/inventory', + '/failed-events', + '/reports', + ]) }) it('never lists a route that has no place in the sidebar', () => { diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 4225bcb..7135020 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -1,4 +1,5 @@ import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import HomeIcon from '@mui/icons-material/Home' import HubOutlinedIcon from '@mui/icons-material/HubOutlined' import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined' @@ -42,6 +43,9 @@ const InventoryPage = lazy(() => const ProductDetailPage = lazy(() => import('features/inventory').then((m) => ({ default: m.ProductDetailPage })), ) +const FailedEventsPage = lazy(() => + import('features/failed-events').then((m) => ({ default: m.FailedEventsPage })), +) const ReportsPage = lazy(() => import('features/reports').then((m) => ({ default: m.ReportsPage }))) const LoginPage = lazy(() => import('features/auth').then((m) => ({ default: m.LoginPage }))) @@ -142,6 +146,11 @@ export const appRoutes: AppRoute[] = [ element: , // Sin `nav`: se llega desde el catálogo, no desde el Sidebar. }, + { + path: '/failed-events', + element: , + nav: { label: 'Eventos fallidos', icon: }, + }, { path: '/reports', element: , diff --git a/src/features/failed-events/api.ts b/src/features/failed-events/api.ts new file mode 100644 index 0000000..2e4734c --- /dev/null +++ b/src/features/failed-events/api.ts @@ -0,0 +1,88 @@ +import { client } from 'shared/api/client' + +import type { + FailedEvent, + FailedEventAction, + FailedEventDirection, + FailedEventFilters, + FailedEventPage, + FailedEventStatus, +} from './types' + +// Frontera con `/api/v1/failed-events`. Ningún componente ve el snake_case. + +interface ApiFailedEvent { + id: number + event_type: string + direction: FailedEventDirection + status: FailedEventStatus + attempts: number + max_attempts: number + next_retry_at: string | null + last_error: string | null + last_response_status: number | null + created_at: string +} + +interface ApiList { + data: T[] + meta: { page: number; per_page: number; total: number } +} + +function toFailedEvent(event: ApiFailedEvent): FailedEvent { + return { + id: event.id, + eventType: event.event_type, + direction: event.direction, + status: event.status, + attempts: event.attempts, + maxAttempts: event.max_attempts, + nextRetryAt: event.next_retry_at, + lastError: event.last_error, + lastResponseStatus: event.last_response_status, + createdAt: event.created_at, + } +} + +// Sin estado no viaja `status=`: la API ignora un estado desconocido, pero uno +// vacío no es "todos" por contrato, es por casualidad. +function toParams({ page, perPage, status }: FailedEventFilters) { + return { page, per_page: perPage, ...(status === undefined ? {} : { status }) } +} + +export async function fetchFailedEventPage(filters: FailedEventFilters): Promise { + const { data } = await client.get>('/failed-events', { + params: toParams(filters), + }) + + return { + events: data.data.map(toFailedEvent), + page: data.meta.page, + perPage: data.meta.per_page, + total: data.meta.total, + } +} + +/** Cuántos eventos hay en un estado, sin traerlos: una fila y el `meta.total`. */ +export async function fetchFailedEventCount(status?: FailedEventStatus): Promise { + const { data } = await client.get>('/failed-events', { + params: toParams({ page: 1, perPage: 1, status }), + }) + + return data.meta.total +} + +/** + * Reintento o descarte manual. El reintento vuelve el evento a `pending` con + * los intentos en cero y lo encola ya, sin esperar al barrido; responde **422** + * si el evento no se puede reintentar (resuelto, o en proceso con un worker + * vivo). El descarte lo saca de la cola para siempre. + */ +export async function actOnFailedEvent( + id: number, + action: FailedEventAction, +): Promise { + const { data } = await client.post(`/failed-events/${id}/${action}`) + + return toFailedEvent(data) +} diff --git a/src/features/failed-events/content.ts b/src/features/failed-events/content.ts new file mode 100644 index 0000000..bcda153 --- /dev/null +++ b/src/features/failed-events/content.ts @@ -0,0 +1,107 @@ +import type { StatusVariant } from 'shared/components' + +import type { FailedEventDirection, FailedEventStatus } from './types' + +// Copy centralizado de la feature — sin literales sueltos en el JSX. + +const numberFormat = new Intl.NumberFormat('es-AR') + +export function formatCount(value: number): string { + return numberFormat.format(value) +} + +/** + * Nombre legible de cada tipo de evento (`Webhooks::ReplayRegistry`). Un tipo + * que no está acá se muestra tal cual: es mejor un nombre técnico que + * esconder que existe. + */ +export const EVENT_TYPE_LABELS: Record = { + 'integrations.http_request': 'Pedido a un canal o courier', + 'webhooks.order_ingestion': 'Venta recibida de un canal', + 'webhooks.tracking_ingestion': 'Novedad de seguimiento de un envío', +} + +export const STATUS_LABELS: Record = { + pending: 'Pendiente', + processing: 'Procesando', + succeeded: 'Resuelto', + dead: 'Agotado', + discarded: 'Descartado', +} + +/** El color nunca va solo: el badge siempre lleva el texto del estado. */ +export const STATUS_VARIANTS: Record = { + pending: 'warning', + processing: 'info', + succeeded: 'success', + dead: 'error', + discarded: 'neutral', +} + +export const DIRECTION_LABELS: Record = { + inbound: 'Entrante', + outbound: 'Saliente', +} + +export const failedEventsCopy = { + page: { + title: 'Eventos fallidos', + subtitle: + 'Webhooks y sincronizaciones que fallaron. El sistema los reintenta solo; acá podés forzar un reintento o descartarlos.', + tableLabel: 'Cola de eventos fallidos', + empty: 'No hay eventos en esta pestaña.', + error: 'No pudimos cargar la cola de eventos.', + }, + tabs: { + all: 'Todos', + pending: 'Pendientes', + dead: 'Agotados', + succeeded: 'Resueltos', + discarded: 'Descartados', + }, + columns: { + event: 'Evento', + status: 'Estado', + attempts: 'Intentos', + nextRetry: 'Próximo reintento', + lastError: 'Último error', + createdAt: 'Registrado', + actions: 'Acciones', + }, + cells: { + attempts: (attempts: number, max: number) => `${attempts} / ${max}`, + noRetry: '—', + noError: 'Sin detalle', + httpStatus: (status: number) => `HTTP ${status}`, + /** Si el reintento ya venció, el barrido lo toma en el próximo minuto. */ + due: 'En la próxima vuelta', + }, + actions: { + retry: 'Reintentar', + discard: 'Descartar', + retryFor: (id: number) => `Reintentar el evento ${id}`, + discardFor: (id: number) => `Descartar el evento ${id}`, + }, + discard: { + title: 'Descartar evento', + body: (label: string) => + `«${label}» deja de reintentarse. Si la causa ya se resolvió, conviene reintentarlo en vez de descartarlo.`, + confirm: 'Descartar', + cancel: 'Volver', + close: 'Cerrar', + }, + feedback: { + retried: 'El evento volvió a la cola y se reintenta ahora.', + discarded: 'Evento descartado.', + notRequeueable: + 'Ese evento ya no admite reintento: cambió de estado. La tabla está actualizada.', + failed: 'No pudimos actualizar el evento. Probá de nuevo.', + }, + pagination: { + previous: 'Página anterior', + next: 'Página siguiente', + page: (page: number) => `Ir a la página ${page}`, + summary: (from: number, to: number, total: number) => + `Mostrando ${from} a ${to} de ${formatCount(total)} ${total === 1 ? 'evento' : 'eventos'}`, + }, +} diff --git a/src/features/failed-events/hooks/useFailedEvents.ts b/src/features/failed-events/hooks/useFailedEvents.ts new file mode 100644 index 0000000..ff1af22 --- /dev/null +++ b/src/features/failed-events/hooks/useFailedEvents.ts @@ -0,0 +1,71 @@ +import { + keepPreviousData, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import type { ApiRequestError } from 'shared/api/types' + +import { actOnFailedEvent, fetchFailedEventCount, fetchFailedEventPage } from '../api' +import { failedEventKeys } from '../queryKeys' +import type { + FailedEvent, + FailedEventAction, + FailedEventFilters, + FailedEventPage, + FailedEventStatus, +} from '../types' + +/** + * Las pestañas, en orden, con el estado que filtra cada una. `processing` no + * tiene pestaña: es un estado de segundos mientras un worker lo intenta, y + * aparece igual en «Todos». + */ +export const QUEUE_TABS = [ + { id: 'all', status: undefined }, + { id: 'pending', status: 'pending' }, + { id: 'dead', status: 'dead' }, + { id: 'succeeded', status: 'succeeded' }, + { id: 'discarded', status: 'discarded' }, +] as const satisfies readonly { id: string; status: FailedEventStatus | undefined }[] + +export type QueueTabId = (typeof QUEUE_TABS)[number]['id'] + +/** Status con el que la API rechaza reintentar un evento que no admite reintento. */ +export const NOT_REQUEUEABLE_STATUS = 422 + +export function useFailedEventPage(filters: FailedEventFilters) { + return useQuery({ + queryKey: failedEventKeys.page(filters), + queryFn: () => fetchFailedEventPage(filters), + // Cambiar de página o de pestaña no vacía la tabla mientras responde la API. + placeholderData: keepPreviousData, + }) +} + +/** Los contadores de las pestañas, en paralelo. Uno que falla deja la pestaña sin número. */ +export function useFailedEventCounts() { + return useQueries({ + queries: QUEUE_TABS.map(({ status }) => ({ + queryKey: failedEventKeys.count(status), + queryFn: () => fetchFailedEventCount(status), + })), + combine: (results) => results.map((result) => result.data), + }) +} + +/** + * Reintento o descarte. Invalida la feature también al fallar: el caso real de + * error es un evento que cambió de estado mientras la tabla estaba abierta (el + * barrido lo resolvió, otro operador lo descartó), y la fila tiene que + * mostrarlo. + */ +export function useFailedEventAction() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, action }) => actOnFailedEvent(id, action), + onSettled: () => queryClient.invalidateQueries({ queryKey: failedEventKeys.all }), + }) +} diff --git a/src/features/failed-events/index.ts b/src/features/failed-events/index.ts new file mode 100644 index 0000000..63f9838 --- /dev/null +++ b/src/features/failed-events/index.ts @@ -0,0 +1 @@ +export { FailedEventsPage } from './pages/FailedEventsPage' diff --git a/src/features/failed-events/pages/FailedEventsPage.test.tsx b/src/features/failed-events/pages/FailedEventsPage.test.tsx new file mode 100644 index 0000000..ff9f574 --- /dev/null +++ b/src/features/failed-events/pages/FailedEventsPage.test.tsx @@ -0,0 +1,141 @@ +import { fireEvent, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' +import type * as queueHooks from '../hooks/useFailedEvents' +import { + useFailedEventAction, + useFailedEventCounts, + useFailedEventPage, +} from '../hooks/useFailedEvents' +import type { FailedEvent } from '../types' + +import { FailedEventsPage } from './FailedEventsPage' + +vi.mock('../hooks/useFailedEvents', async (importOriginal) => ({ + ...(await importOriginal()), + useFailedEventPage: vi.fn(), + useFailedEventCounts: vi.fn(), + useFailedEventAction: vi.fn(), +})) + +const FETCHED_AT = new Date('2026-10-02T12:00:00Z').getTime() + +const DEAD: FailedEvent = { + id: 11, + eventType: 'webhooks.order_ingestion', + direction: 'inbound', + status: 'dead', + attempts: 5, + maxAttempts: 5, + nextRetryAt: null, + lastError: 'product not found for external id 123', + lastResponseStatus: null, + createdAt: '2026-10-02T10:00:00Z', +} + +const PENDING: FailedEvent = { + ...DEAD, + id: 12, + eventType: 'integrations.http_request', + direction: 'outbound', + status: 'pending', + attempts: 2, + nextRetryAt: '2026-10-02T12:04:00Z', + lastError: 'Service Unavailable', + lastResponseStatus: 503, +} + +const SUCCEEDED: FailedEvent = { ...DEAD, id: 13, status: 'succeeded', lastError: null } + +const mutate = vi.fn() + +function showEvents(events: FailedEvent[]) { + vi.mocked(useFailedEventPage).mockReturnValue({ + data: { events, page: 1, perPage: 20, total: events.length }, + dataUpdatedAt: FETCHED_AT, + isPending: false, + isError: false, + } as never) +} + +beforeEach(() => { + mutate.mockReset() + showEvents([DEAD, PENDING, SUCCEEDED]) + vi.mocked(useFailedEventCounts).mockReturnValue([3, 1, 1, 1, 0]) + vi.mocked(useFailedEventAction).mockReturnValue({ mutate, isPending: false } as never) +}) + +const row = (text: string) => { + const cell = screen.getAllByText(text)[0] + const tableRow = cell?.closest('tr') + if (tableRow === null || tableRow === undefined) throw new Error(`no row for ${text}`) + return within(tableRow) +} + +describe('FailedEventsPage', () => { + it('names each event in words with its direction, status and attempts', () => { + renderWithTheme() + + const pending = row('Pedido a un canal o courier') + expect(pending.getByText('Saliente')).toBeInTheDocument() + expect(pending.getByText('Pendiente')).toBeInTheDocument() + expect(pending.getByText('2 / 5')).toBeInTheDocument() + }) + + it('shows the last error with the HTTP status of the answer', () => { + renderWithTheme() + + expect(row('Service Unavailable').getByText('HTTP 503')).toBeInTheDocument() + }) + + it('says when the next automatic retry is due', () => { + renderWithTheme() + + expect(row('Service Unavailable').getByText('dentro de 4 minutos')).toBeInTheDocument() + }) + + it('shows the count of each tab', () => { + renderWithTheme() + + expect(screen.getByRole('tab', { name: /Agotados/ })).toHaveTextContent('1') + }) + + it('retries an exhausted event right away', () => { + renderWithTheme() + + fireEvent.click(screen.getByRole('button', { name: 'Reintentar el evento 11' })) + + expect(mutate).toHaveBeenCalledWith({ id: 11, action: 'retry' }, expect.anything()) + }) + + it('asks before discarding and then discards', () => { + renderWithTheme() + + fireEvent.click(screen.getByRole('button', { name: 'Descartar el evento 12' })) + fireEvent.click( + within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Descartar' }), + ) + + expect(mutate).toHaveBeenCalledWith({ id: 12, action: 'discard' }, expect.anything()) + }) + + it('offers no action on an event that already went through', () => { + renderWithTheme() + + expect(screen.queryByRole('button', { name: 'Reintentar el evento 13' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Descartar el evento 13' })).toBeNull() + }) + + it('asks the API for the status of the chosen tab, from the first page', () => { + renderWithTheme() + + fireEvent.click(screen.getByRole('tab', { name: /Agotados/ })) + + expect(vi.mocked(useFailedEventPage)).toHaveBeenLastCalledWith({ + page: 1, + perPage: 20, + status: 'dead', + }) + }) +}) diff --git a/src/features/failed-events/pages/FailedEventsPage.tsx b/src/features/failed-events/pages/FailedEventsPage.tsx new file mode 100644 index 0000000..9f786af --- /dev/null +++ b/src/features/failed-events/pages/FailedEventsPage.tsx @@ -0,0 +1,246 @@ +import { Box, Button, Stack, Typography } from '@mui/material' +import { useMemo, useState } from 'react' +import { + ConfirmDialog, + DataTable, + ErrorFallback, + LoadingSpinner, + PageWrapper, + StackedCell, + StatusBadge, +} from 'shared/components' +import type { DataTableColumn, DataTableTab } from 'shared/components' +import { notify } from 'shared/store' +import { formatRelativeTime } from 'shared/utils' + +import { + DIRECTION_LABELS, + STATUS_LABELS, + STATUS_VARIANTS, + failedEventsCopy, + formatCount, +} from '../content' +import { + NOT_REQUEUEABLE_STATUS, + QUEUE_TABS, + useFailedEventAction, + useFailedEventCounts, + useFailedEventPage, +} from '../hooks/useFailedEvents' +import type { QueueTabId } from '../hooks/useFailedEvents' +import type { FailedEvent, FailedEventAction } from '../types' +import { actionsFor, eventLabel } from '../utils/actions' + +const { + page, + tabs: tabCopy, + columns, + cells, + actions: actionCopy, + discard, + feedback, +} = failedEventsCopy + +const PER_PAGE = 20 + +function nextRetryCell(event: FailedEvent, now: Date): string { + if (event.nextRetryAt === null) return cells.noRetry + if (new Date(event.nextRetryAt).getTime() <= now.getTime()) return cells.due + + return formatRelativeTime(event.nextRetryAt, now) ?? cells.noRetry +} + +function lastErrorCell(event: FailedEvent) { + const status = + event.lastResponseStatus === null ? undefined : cells.httpStatus(event.lastResponseStatus) + + return +} + +/** + * Cola de eventos fallidos (Dead Letter Queue, RF-16): lo que el motor de + * reintentos todavía intenta, lo que agotó sus intentos y lo que se resolvió. + * + * No tiene maqueta en `docs/design/`: es la tabla del DS con las pestañas por + * estado del catálogo. Existe para que la resiliencia del sistema se pueda ver + * y operar, no sólo confiar en que pasa. + */ +export function FailedEventsPage() { + const [tabId, setTabId] = useState('all') + const [pageNumber, setPageNumber] = useState(1) + const [discarding, setDiscarding] = useState(undefined) + + const status = QUEUE_TABS.find((tab) => tab.id === tabId)?.status + const events = useFailedEventPage({ page: pageNumber, perPage: PER_PAGE, status }) + const counts = useFailedEventCounts() + const action = useFailedEventAction() + + // Un solo "ahora" por lectura de la API: el de cuando llegaron los datos. Sin + // esto cada celda calcularía el suyo, y un re-render cualquiera movería los + // "en 3 minutos" sin que la tabla haya cambiado. + const fetchedAt = events.dataUpdatedAt + const now = useMemo(() => new Date(fetchedAt), [fetchedAt]) + + function run(event: FailedEvent, kind: FailedEventAction) { + action.mutate( + { id: event.id, action: kind }, + { + onSuccess: () => + notify(kind === 'retry' ? feedback.retried : feedback.discarded, 'success'), + onError: (error) => + notify( + error.status === NOT_REQUEUEABLE_STATUS ? feedback.notRequeueable : feedback.failed, + error.status === NOT_REQUEUEABLE_STATUS ? 'info' : 'error', + ), + onSettled: () => setDiscarding(undefined), + }, + ) + } + + const tableColumns: DataTableColumn[] = [ + { + id: 'event', + header: columns.event, + render: (event) => ( + + ), + }, + { + id: 'status', + header: columns.status, + width: 140, + render: (event) => ( + + ), + }, + { + id: 'attempts', + header: columns.attempts, + align: 'right', + width: 110, + render: (event) => cells.attempts(event.attempts, event.maxAttempts), + }, + { + id: 'nextRetry', + header: columns.nextRetry, + width: 170, + render: (event) => nextRetryCell(event, now), + }, + { id: 'lastError', header: columns.lastError, render: lastErrorCell }, + { + id: 'createdAt', + header: columns.createdAt, + width: 150, + render: (event) => formatRelativeTime(event.createdAt, now) ?? cells.noRetry, + }, + { + id: 'actions', + header: columns.actions, + align: 'right', + width: 220, + // Botones visibles y no el menú de acciones del DataTable: ese menú no + // admite opciones por fila, y acá cada estado acepta cosas distintas. + render: (event) => ( + + {actionsFor(event.status).includes('discard') ? ( + + ) : null} + {actionsFor(event.status).includes('retry') ? ( + + ) : null} + + ), + }, + ] + + const tabs: DataTableTab[] = QUEUE_TABS.map(({ id }, index) => { + const count = counts[index] + return { + id, + label: tabCopy[id], + ...(count === undefined ? {} : { count: formatCount(count) }), + } + }) + + if (events.isPending) return + + if (events.isError) { + return void events.refetch()} /> + } + + const { total } = events.data + const pageCount = Math.max(1, Math.ceil(total / PER_PAGE)) + const from = total === 0 ? 0 : (pageNumber - 1) * PER_PAGE + 1 + const to = Math.min(pageNumber * PER_PAGE, total) + + return ( + + + + {page.title} + + + {page.subtitle} + + + + event.id} + label={page.tableLabel} + tabs={tabs} + activeTabId={tabId} + onTabChange={(next) => { + setTabId(next as QueueTabId) + setPageNumber(1) + }} + rowTone={(event) => (event.status === 'dead' ? 'critical' : 'default')} + pagination={{ + page: pageNumber, + pageCount, + summary: failedEventsCopy.pagination.summary(from, to, total), + onPageChange: setPageNumber, + }} + paginationLabels={{ + previousLabel: failedEventsCopy.pagination.previous, + nextLabel: failedEventsCopy.pagination.next, + pageLabel: failedEventsCopy.pagination.page, + }} + emptyMessage={page.empty} + /> + + { + if (discarding !== undefined) run(discarding, 'discard') + }} + onClose={() => setDiscarding(undefined)} + /> + + ) +} diff --git a/src/features/failed-events/queryKeys.ts b/src/features/failed-events/queryKeys.ts new file mode 100644 index 0000000..add5400 --- /dev/null +++ b/src/features/failed-events/queryKeys.ts @@ -0,0 +1,11 @@ +import type { FailedEventFilters, FailedEventStatus } from './types' + +// Factory de query keys de la feature — nunca literales sueltos en los hooks. +export const failedEventKeys = { + all: ['failed-events'] as const, + page: (filters: FailedEventFilters) => [...failedEventKeys.all, 'page', filters] as const, + // `status ?? 'all'`: sin esto la clave de «todos» dependería de cómo se + // serializa un `undefined`. + count: (status: FailedEventStatus | undefined) => + [...failedEventKeys.all, 'count', status ?? 'all'] as const, +} diff --git a/src/features/failed-events/types.ts b/src/features/failed-events/types.ts new file mode 100644 index 0000000..76cefda --- /dev/null +++ b/src/features/failed-events/types.ts @@ -0,0 +1,42 @@ +// Tipos de la cola de eventos fallidos (Dead Letter Queue), en camelCase. + +/** `FailedEvent::STATUSES`. */ +export type FailedEventStatus = 'pending' | 'processing' | 'succeeded' | 'dead' | 'discarded' + +/** Entrante (un webhook que nos llegó) o saliente (un pedido nuestro a un tercero). */ +export type FailedEventDirection = 'inbound' | 'outbound' + +/** + * Un evento de integración que falló y espera, o esperó, un reintento. + * Espejo de `GET /api/v1/failed-events`. Sin `payload` ni cuerpo de la + * respuesta: la API no los expone porque pueden traer datos del cliente. + */ +export interface FailedEvent { + id: number + eventType: string + direction: FailedEventDirection + status: FailedEventStatus + attempts: number + maxAttempts: number + /** `null` cuando ya no se va a reintentar solo (resuelto, agotado, descartado). */ + nextRetryAt: string | null + lastError: string | null + lastResponseStatus: number | null + createdAt: string +} + +export interface FailedEventFilters { + page: number + perPage: number + /** Sin estado, la API devuelve todos. */ + status?: FailedEventStatus +} + +export interface FailedEventPage { + events: FailedEvent[] + page: number + perPage: number + total: number +} + +export type FailedEventAction = 'retry' | 'discard' diff --git a/src/features/failed-events/utils/actions.test.ts b/src/features/failed-events/utils/actions.test.ts new file mode 100644 index 0000000..e5c5806 --- /dev/null +++ b/src/features/failed-events/utils/actions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import type { FailedEvent } from '../types' + +import { actionsFor, eventLabel } from './actions' + +const EVENT: FailedEvent = { + id: 1, + eventType: 'webhooks.order_ingestion', + direction: 'inbound', + status: 'dead', + attempts: 5, + maxAttempts: 5, + nextRetryAt: null, + lastError: 'timeout', + lastResponseStatus: 504, + createdAt: '2026-10-01T10:00:00Z', +} + +// Espejo de `RequeueFailedEvent::REQUEUEABLE_STATUSES`: ofrecer lo que la API +// rechaza con 422 sería prometer algo que no pasa. +describe('actionsFor', () => { + it('offers retrying and discarding an exhausted event', () => { + expect(actionsFor('dead')).toEqual(['retry', 'discard']) + }) + + it('lets a discarded event come back, but not be discarded twice', () => { + expect(actionsFor('discarded')).toEqual(['retry']) + }) + + it('offers nothing for an event that already went through', () => { + expect(actionsFor('succeeded')).toEqual([]) + }) + + // La API lo acepta sólo con el claim vencido, y eso el front no lo ve. + it('does not offer retrying an event a worker is processing', () => { + expect(actionsFor('processing')).not.toContain('retry') + }) +}) + +describe('eventLabel', () => { + it('names the known event types in words', () => { + expect(eventLabel(EVENT)).toBe('Venta recibida de un canal') + }) + + it('shows an unknown type as it comes instead of hiding it', () => { + expect(eventLabel({ ...EVENT, eventType: 'webhooks.something_new' })).toBe( + 'webhooks.something_new', + ) + }) +}) diff --git a/src/features/failed-events/utils/actions.ts b/src/features/failed-events/utils/actions.ts new file mode 100644 index 0000000..bbc3932 --- /dev/null +++ b/src/features/failed-events/utils/actions.ts @@ -0,0 +1,27 @@ +import { EVENT_TYPE_LABELS } from '../content' +import type { FailedEvent, FailedEventAction, FailedEventStatus } from '../types' + +// Qué acciones acepta la API en cada estado. Es la regla de +// `Webhooks::RequeueFailedEvent::REQUEUEABLE_STATUSES` vista desde la pantalla: +// ofrecer un botón que la API va a rechazar con 422 es prometer algo que no +// pasa. +// +// `processing` no ofrece reintento: la API lo acepta sólo si el claim del +// worker venció, y eso el front no lo puede saber. Si quedó colgado, el barrido +// del cronjob lo rescata solo; si no, el operador lo puede descartar. +const ACTIONS: Record = { + pending: ['retry', 'discard'], + processing: ['discard'], + dead: ['retry', 'discard'], + discarded: ['retry'], + succeeded: [], +} + +export function actionsFor(status: FailedEventStatus): readonly FailedEventAction[] { + return ACTIONS[status] +} + +/** Nombre legible del tipo de evento, o el tipo tal cual si no se conoce. */ +export function eventLabel(event: FailedEvent): string { + return EVENT_TYPE_LABELS[event.eventType] ?? event.eventType +}