diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 660a1a7..d84b845 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -315,6 +315,7 @@ features/[nombre]/ | `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` | | `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar | +| `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 0702781..661b9f2 100644 --- a/src/app/router/routes.test.tsx +++ b/src/app/router/routes.test.tsx @@ -30,6 +30,7 @@ describe('navRoutesFor', () => { '/orders', '/inventory', '/warehouses', + '/failed-events', '/reports', ]) }) diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index cfcae09..061841f 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' @@ -46,6 +47,9 @@ const ProductDetailPage = lazy(() => const WarehousesPage = lazy(() => import('features/warehouses').then((m) => ({ default: m.WarehousesPage })), ) +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 }))) @@ -151,6 +155,11 @@ export const appRoutes: AppRoute[] = [ element: , nav: { label: 'Depósitos', icon: }, }, + { + 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 +}