Skip to content

fix: publicar eventos del outbox, build ejecutable y autorización de comentarios - #12

Open
AZapata27 wants to merge 6 commits into
masterfrom
fix/outbox-build-authz
Open

AZapata27 wants to merge 6 commits into
masterfrom
fix/outbox-build-authz

Conversation

@AZapata27

Copy link
Copy Markdown
Owner

Problema

  1. El outbox nunca publicaba. Nadie invocaba OutboxService.processPendingEvents(), así que los eventos se quedaban en PENDING para siempre. Además, el paso a PROCESSING se hacía con save() sin condición, lo que abría la puerta a publicar dos veces si había varias réplicas.
  2. El build no generaba servicios ejecutables. @nx/js:swc no reescribía los alias de paths ni copiaba las libs, y node dist/apps/gateway/src/main.js fallaba con Cannot find module '@gateway/modules/auth/auth.module'. Al arreglarlo aparecieron errores de arranque que ese build roto ocultaba:
    • OutboxModule no podía inyectar KafkaService (KafkaModule no era global), así que auth, ticket y assignment-service abortaban el bootstrap.
    • Columnas string | null sin type (refresh_token, sprint_id, parent_ticket_id) hacían que TypeORM rechazara el esquema con DataTypeNotSupportedError.
    • El glob de entidades sobre __dirname no encontraba archivos dentro de un bundle.
  3. Cualquiera podía borrar cualquier comentario. CommentsService.delete ignoraba al solicitante.

Qué cambia

Outbox (libs/common/src/outbox)

  • OutboxScheduler (dentro de OutboxModule) arranca en onApplicationBootstrap y llama a processPendingEvents() cada OUTBOX_POLL_INTERVAL_MS. Un error en una corrida no detiene el timer, y el timer se limpia en el shutdown.
  • Claim atómico optimista: cada evento pasa de PENDING a PROCESSING con un UPDATE condicional (id, status, retry_count, vencimiento). Solo quien obtiene affected = 1 publica, así que no hay doble publicación entre réplicas. También hay un guard para que dos corridas no se solapen dentro del mismo proceso.
  • Reintentos: se usa backoff exponencial guardado en la nueva columna next_attempt_at (base × 2^(n-1), con tope). El evento pasa a FAILED al llegar a OUTBOX_MAX_ATTEMPTS.
  • Recuperación: un evento que lleva en PROCESSING más de OUTBOX_PROCESSING_TIMEOUT_MS (porque la instancia murió a mitad) se vuelve a reclamar. La entrega es at-least-once.
  • Configuración por variables de entorno, todas opcionales:
Variable Default
OUTBOX_ENABLED true
OUTBOX_POLL_INTERVAL_MS 5000
OUTBOX_BATCH_SIZE 100
OUTBOX_MAX_ATTEMPTS 5
OUTBOX_RETRY_BASE_DELAY_MS 1000
OUTBOX_RETRY_MAX_DELAY_MS 60000
OUTBOX_PROCESSING_TIMEOUT_MS 60000

Build

  • Las 5 apps pasan a @nx/webpack:webpack (compiler: tsc, con apps/<app>/webpack.config.js usando composePlugins(withNx())). Es el setup estándar de Nx para apps Nest.
    • Resuelve los alias y embebe las libs.
    • node_modules queda externo.
    • Se mantienen skipTypeChecking y el target typecheck aparte.
  • Entidades de TypeORM en lista explícita por servicio, incluyendo OutboxEvent.
  • KafkaModule ahora es global: true.
  • type: 'varchar' explícito en las columnas nullable.

Autorización de comentarios

  • CommentsService.delete(id, { userId, role }) lanza ForbiddenException (403) si quien pide no es el autor ni ADMIN.

Redis

  • Se quita RedisModule.register de los 5 AppModule, porque ningún flujo lo usaba y solo agregaba una dependencia de arranque. RedisModule sigue en @app/common y Redis en docker-compose.yml.
  • README: diagrama Mermaid sin Redis, notas del relay y de la autorización, y cómo ejecutar el bundle.

Cómo se usa

npm run infra:up
npx nx build ticket-service
node dist/apps/ticket-service/main.js   # desde la raíz del repo

El relay arranca solo con cada servicio que importa OutboxModule. Para desactivarlo: OUTBOX_ENABLED=false.

Casos borde

  • Esquema: se agrega la columna outbox_events.next_attempt_at (nullable). Hoy el esquema se crea con synchronize en development y el repo no tiene migraciones. En un entorno con synchronize: false hay que crearla a mano: ALTER TABLE outbox_events ADD COLUMN next_attempt_at timestamp NULL;.
  • Duplicados: si una instancia muere entre publicar y marcar SENT, el evento se reenvía después del timeout. Los consumidores deben ser idempotentes por eventId.
  • Gateway → ticket-service: el gateway reenvía solo x-user-id, y ticket-service no tiene estrategia que llene request.user a partir de esa cabecera. Este PR aplica la regla autor/ADMIN sobre request.user, pero la propagación de identidad entre gateway y servicios es un problema previo que queda fuera de alcance.
  • Compatibilidad con feat(deps): actualizar Nx 23 y NestJS 11 #11 (Nx 23 / NestJS 11): este PR es independiente de master. Lo verifiqué mergeándolo localmente sobre feat(deps): actualizar Nx 23 y NestJS 11 #11: merge sin conflictos y todo en verde. Nx 23 marca @nx/webpack:webpack, composePlugins/withNx y skipTypeChecking como deprecated para Nx 24, igual que los executors de jest y eslint que ya existen. La migración a targets inferidos queda para un PR posterior.

Validación

  • npm ci limpio y luego npx nx run-many -t lint typecheck test build --skip-nx-cache: OK en los 7 proyectos. Tests: 92 (antes 71).
Proyecto Antes Después
common 12 24
ticket-service 18 27
auth-service 14 14
gateway 11 11
notification-service 9 9
assignment-service 7 7
  • Tests nuevos del outbox:
    • Publicación exitosa.
    • Fallo con backoff y reintento posterior.
    • FAILED al llegar al máximo de intentos.
    • Evento ya reclamado por otro proceso: no se publica.
    • Dos réplicas concurrentes: cada evento se publica una sola vez.
    • Sin solapamiento de corridas en el mismo proceso.
    • Tope del backoff.
    • Scheduler: intervalo, sigue tras un error, desactivado.
    • Parseo de la configuración.
  • Tests nuevos de comentarios:
    • Unitarios: autor, ADMIN, 4 variantes de 403 y 404.
    • HTTP real con el controller: 403, 200 (autor), 200 (ADMIN) y 404.
  • Verificación contra PostgreSQL real (cluster local temporal, test descartable que no se commitea):
    • 4 réplicas concurrentes publicaron 200 eventos exactamente una vez.
    • El backoff se respeta y el evento termina en FAILED tras 5 intentos.
    • Un PROCESSING abandonado se reclama después del timeout.
  • Arranque de los bundles:
    • gateway: arranca y escucha en :3000.
    • ticket-service y auth-service contra Postgres: conectan, synchronize crea las tablas (incluida outbox_events.next_attempt_at) y fallan solo por Kafka (KafkaJSNumberOfRetriesExceeded).
    • Sin base de datos: fallan solo en Unable to connect to the database.
    • notification-service: falla solo por Kafka.
    • Antes, con SWC: Cannot find module '@gateway/modules/auth/auth.module'.

🤖 Generated with Claude Code

AZapata27 and others added 6 commits October 13, 2026 11:15
…cutable

El build con @nx/js:swc no reescribía los alias de paths (@app/*, @<app>/*)
ni copiaba las libs del workspace, así que node dist/... fallaba con
'Cannot find module'. Ahora cada app se empaqueta con webpack (resuelve
alias y embebe libs; node_modules quedan externos) y las entidades de
TypeORM se registran explícitamente porque el glob sobre __dirname no
encuentra archivos dentro del bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mico

- OutboxScheduler ejecuta processPendingEvents cada OUTBOX_POLL_INTERVAL_MS
- claim optimista PENDING -> PROCESSING con UPDATE condicional (sin doble publicación entre réplicas)
- backoff exponencial (next_attempt_at) y FAILED tras OUTBOX_MAX_ATTEMPTS
- recupera eventos PROCESSING abandonados tras OUTBOX_PROCESSING_TIMEOUT_MS
- tests de éxito, fallo con reintento, FAILED, concurrencia y scheduler

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CommentsService.delete ignoraba al solicitante: cualquier usuario autenticado
podía borrar cualquier comentario. Ahora devuelve 403 (ForbiddenException)
si quien pide no es el autor ni tiene rol ADMIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- KafkaModule global: OutboxModule no podía inyectar KafkaService y Nest
  abortaba el bootstrap de auth/ticket/assignment-service
- tipo explícito en columnas nullable (refresh_token, sprint_id,
  parent_ticket_id): con 'string | null' la metadata es Object y TypeORM
  rechazaba el esquema en Postgres
- spec de assignments con el claim del outbox y OUTBOX_OPTIONS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ningún flujo leía ni escribía en Redis; RedisModule sigue en @app/common.
README: diagrama sin Redis, relay del outbox, autorización de comentarios
y cómo ejecutar el bundle construido.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant