Skip to main content

Automaciones

Sistema de notificaciones event-driven. Cualquier acción de negocio (crear reserva, completar encuesta, etc.) puede emitir un evento de dominio; los flujos configurados en el backoffice escuchan esos eventos y disparan acciones (push, in_app, tools_inbox, email, chat). Ruta del backoffice: /app/notifications/automations

Modelo conceptual

                                                ┌─────────────────────┐
vivla-backend ──POST /public/events/ingest────►│                     │
                                                │  domain_events      │──► Windmill ──► flows ──► acciones
External systems ──POST /public/events/ingest─►│  (vivla-tools)      │
                                                │                     │
vivla-tools internal ──ingestEvent() directo──►│                     │
                                                └─────────────────────┘
Cada flujo es lineal e inmediato: trigger → (condition opcional) → action. No hay step de “espera” — para disparos no-inmediatos (3 días antes, 24h después, etc.), se usan eventos derivados emitidos por crons diarios.

Step builder

El builder visual usa drag-and-drop sobre un canvas (xyflow). Tres tipos de step:
StepPropósitoConfiguración
TriggerPunto de entrada (uno por flujo)Selecciona el event_type del catálogo
ConditionBifurca según campo del payloadReglas con AND/OR sobre payload.field operador (eq, neq, gt, lt, in)
ActionEnvía la notificaciónCanales (push, in_app, tools_inbox, email, chat) + target user id + template multi-idioma
Al guardar, el grafo se valida con validateDAG (un solo trigger, action es leaf node, sin ciclos).

Catálogo de eventos

Lista vivamantenida (también disponible vía GET /public/events/registered-events y en el catálogo informativo de packages/common).

Emitidos por vivla-backend (HTTP POST)

Onboardinguser.welcomed, user.first_login Reservasbooking.created, booking.info_updated, booking.cancelled, booking.guest_invited, booking.rent_requested, booking.rent_approved (🔴 crítico), booking.rent_created_by_admin, booking.exchange_executed (bidireccional), booking.liberated, booking.third_home_published, booking.third_home_exchanged Llaveskeys.recovered, keys.expiring_soon, keys.extended, keys.fidelity_bonus_credited (los últimos tres desde crons internos del backend)

Emitidos internamente por vivla-tools

Encuestassurvey.completed (ramifica por lowestNormalized, la nota más baja normalizada a /10: absorbe los antiguos nps_high/nps_low)

Emitidos por crons de Windmill

Oportunidadesbooking.stays_available_reminder (mensual) Lifecycle de usuariouser.inactive (60d sin uso de llaves)
Eventos descartados en la revisión con CX (no emitir, el ingest los rechaza): booking.listed_for_exchange, booking.exchange_requested, booking.exchange_matched, booking.dates_changed, booking.rent_rejected, booking.rent_opportunity, user.payment_not_setup, survey.nps_high, survey.nps_low. Detalle en Events API → Eventos descartados.
Eventos derivados (reemplazan los antiguos patterns delay+condition):
EventoCronReemplaza
booking.pre_stay_30d_duedaily_pre_stay_30d 09:00”delay -30d desde startDate”
booking.pre_stay_7d_duedaily_pre_stay_7d 09:00 (stand-by)“delay -7d desde startDate”
booking.pre_stay_3d_duedaily_pre_stay_3d 09:00”delay -3d desde startDate”
booking.arrival_review_duedaily_arrival_review 10:00”delay +1d desde startDate” — lanza arrival_review survey
booking.stay_review_duedaily_stay_review 10:00”delay +4h desde endDate” → morning-after — lanza stay_review survey
survey.reminder_3d_duedaily_survey_reminder 09:00”delay 3d + condition no respondida”

API pública para integradores

POST /public/events/ingest — el único path por el que cualquier sistema externo (incluyendo vivla-backend) introduce eventos. Convenciones de la API v1: response envelope { object, id, type, status, created_at }, idempotencia vía header Idempotency-Key, errores estructurados con error.code, headers de rate limit y X-Request-ID. Detalle exhaustivo en Events API.

Request resumido

POST /api/public/events/ingest
Content-Type: application/json
x-api-key: <NOTIFICATIONS_API_KEY>
Idempotency-Key: booking_abc123_created   ← recomendado

{
  "event_type": "booking.created",
  "aggregate_id": "booking_abc123",
  "aggregate_type": "booking",
  "source": "vivla-backend",
  "payload": {
    "bookingId": "booking_abc123",
    "ownerId": "user_xyz",
    "ownerName": "Juan Pérez",
    "ownerEmail": "juan@example.com",
    "propertyId": "prop_001",
    "propertyName": "Casa del Mar",
    "propertyLocation": "Ibiza",
    "startDate": "2026-06-15",
    "endDate": "2026-06-22"
  }
}
Nota v3.1: el body field event_id fue eliminado. La idempotencia se hace siempre vía header Idempotency-Key. Internamente se hashea de forma determinística al campo UNIQUE domain_events.event_id. Detalle en Idempotencia.

Response shapes

// 202 Accepted (primer POST)
{ "object": "event", "id": "...", "type": "booking.created", "status": "pending", "created_at": "..." }

// 200 OK (Idempotency-Key repetida con mismo body)
{ "object": "event", "id": "...", "type": "booking.created", "status": "idempotent", "created_at": "..." }

// 400 / 401 / 409 / 429 / 5xx → error envelope (ver Códigos de error)

Garantías del servidor

  • Idempotencia vía hash determinístico de Idempotency-Keydomain_events.event_id UNIQUE.
  • Validación estricta por event_type — Zod schemas en apps/backend/src/notifications/engine/schemas/event-schemas.ts.
  • Whitelist de tipos — sólo se aceptan los registrados.
  • Rate limiting: 200 req/min con headers X-RateLimit-Limit/Remaining/Reset y Retry-After en 429.
  • Observabilidad — logs estructurados con request_id + métrica PostHog event.ingested.
  • Health checkGET /public/events/health.

Listar eventos soportados

GET /api/public/events/registered-events
→ { "object": "list", "data": ["user.welcomed", "booking.created", ...] }
Referencia completa con payload por evento, headers, ejemplos y status codes: ver Events API.

Endpoints CRUD del builder

Aparte del endpoint público de ingest, el backoffice usa los siguientes (auth Auth0 + RolesGuard, envelope v1):
MétodoEndpointDescripción
GET/api/v1/notifications/automationsListar flujos (envelope { object: "list", data, has_more, next_cursor })
GET/api/v1/notifications/automations/:idObtener un flujo
POST/api/v1/notifications/automationsCrear flujo (acepta Idempotency-Key)
PUT/api/v1/notifications/automations/:idActualizar flujo
POST/api/v1/notifications/automations/:id/toggleActivar/desactivar
POST/api/v1/notifications/automations/:id/duplicateDuplicar (acepta Idempotency-Key)
POST/api/v1/notifications/automations/:id/validateValidar contra el trigger sin guardar
POST/api/v1/notifications/automations/:id/testTest mode: ejecuta walkFlow con targetOverride = current user. Body: { payload?: object, dry_run?: boolean }. Útil para probar un flow contra uno mismo antes de activarlo
DELETE/api/v1/notifications/automations/:idEliminar
Las rutas legacy /api/notifications/automations/* siguen vivas pero están marcadas como deprecadas — el frontend ya consume /v1. Ver Convenciones API para el envelope.

Endpoints de operaciones (admin)

Inspección del pipeline de eventos. Todos requieren rol admin.
MétodoEndpointDescripción
GET/api/v1/events?status=&event_type=&source=&user_id=&limit=&cursor=Lista cursor-paginated. user_id filtra por payload.userId / ownerId / guestId
GET/api/v1/events/:idDetalle (incluye payload completo)
POST/api/v1/events/:id/replayReset status='pending' + retry_count=0 para reprocesar un evento failed

Mantenimiento

Endpoints x-api-key-protegidos para crons de Windmill (no llamar manualmente):
MétodoEndpointAcción
POST/api/v1/admin/maintenance/idempotency-keys/cleanupBorra idempotency_keys con expires_at < now()
POST/api/v1/admin/maintenance/domain-events/cleanupSoft-delete domain_events con created_at >90 días

Frequency warnings

SendingService corre un check soft antes de cada push: cuenta los pushes a ese user en últimas 24h y 7d. Si excede 1/día o 3/semana, NO bloquea el envío pero:
  • Loggea warning con notification_id, user_id, conteos.
  • Emite evento PostHog notification.frequency_warning con cap_type (daily / weekly / both).
El threshold viene del PRD initial (1/día, 3/semana) y por ahora es informativo. Si los warnings son consistentemente bajos, no se enforce; si son altos hay que decidir si se aplican como hard caps o se relajan.

Modelo de datos

automation_flows (vivla-tools Postgres):
ColumnaTipoDescripción
idUUIDPK
nameJSONB (i18n){ es, en }
descriptionJSONB (i18n)Opcional
trigger_eventstringTipo de evento que dispara
graphJSONB{ nodes, edges } del builder
is_activebooleanSi está corriendo
run_countintegerVeces ejecutado
last_run_attimestampÚltima ejecución
domain_events (vivla-tools Postgres):
ColumnaTipoDescripción
idUUIDPK
event_idUUID UNIQUEHash determinístico del header Idempotency-Key. Usado para dedupe a nivel DB.
event_typestringTipo
aggregate_idstringEntidad principal
aggregate_typestringCategoría
payloadJSONBDatos del evento
sourcestringQuién lo emitió
statusstringpending / published / failed
retry_count, last_errorPara reintentos

Pipeline de procesamiento

[caller] ──POST /public/events/ingest──► domain_events (status=pending)

                                  ┌─────────────┴─────────────┐
                                  ▼                            ▼
                       Windmill `process_pending`        Windmill `health_check`
                       (cron 30s)                        (cron 5min)
                                  │                            │
                                  ▼                            ▼
                       walkFlow → action                  alert si pending > 5min


                       status=published
v3.0: ya no existe el delay step ni la tabla scheduled_notifications. Los disparos no-inmediatos se modelan como eventos derivados emitidos por crons diarios.

Suppressions (opt-out)

Ronda D agregó la tabla notification_suppressions (migración 089). El motor consulta PreferencesService.findActiveSuppression() antes de cada send (push/email/in_app/tools_inbox/chat) y skipea con un log si hay match — la flow se sigue ejecutando, simplemente este canal no dispara para ese destinatario. Reglas de match (en orden):
  1. scope='all' con el canal correspondiente bloquea todo el canal para ese user.
  2. scope='category' matchea cuando scope_value === template.category.
  3. scope='template' matchea cuando scope_value === template_id.
Origen típico: clicks de unsubscribe (source='list_unsubscribe'), también la UI de preferencias del usuario o inserciones manuales de support. Inspección desde SQL:
SELECT s.user_id, u.email, s.channel, s.scope, s.scope_value, s.source, s.created_at
FROM notification_suppressions s
JOIN users u ON u.id = s.user_id
WHERE u.email = 'foo@example.com'
ORDER BY s.created_at DESC;
Eliminar manualmente (re-suscribir):
DELETE FROM notification_suppressions
WHERE user_id = '...' AND channel = 'email' AND scope = 'category' AND scope_value = 'promotional';
Detalle de las best practices transaccionales (preheader, List-Unsubscribe, UTM, Schema.org, color-scheme) en Email.

Referencias para integradores