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:
| Step | Propósito | Configuración |
|---|
| Trigger | Punto de entrada (uno por flujo) | Selecciona el event_type del catálogo |
| Condition | Bifurca según campo del payload | Reglas con AND/OR sobre payload.field operador (eq, neq, gt, lt, in) |
| Action | Envía la notificación | Canales (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)
Onboarding — user.welcomed, user.first_login
Reservas — booking.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
Llaves — keys.recovered, keys.expiring_soon, keys.extended, keys.fidelity_bonus_credited (los últimos tres desde crons internos del backend)
Encuestas — survey.completed (ramifica por lowestNormalized, la nota más baja normalizada a /10: absorbe los antiguos nps_high/nps_low)
Emitidos por crons de Windmill
Oportunidades — booking.stays_available_reminder (mensual)
Lifecycle de usuario — user.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):
| Evento | Cron | Reemplaza |
|---|
booking.pre_stay_30d_due | daily_pre_stay_30d 09:00 | ”delay -30d desde startDate” |
booking.pre_stay_7d_due | daily_pre_stay_7d 09:00 (stand-by) | “delay -7d desde startDate” |
booking.pre_stay_3d_due | daily_pre_stay_3d 09:00 | ”delay -3d desde startDate” |
booking.arrival_review_due | daily_arrival_review 10:00 | ”delay +1d desde startDate” — lanza arrival_review survey |
booking.stay_review_due | daily_stay_review 10:00 | ”delay +4h desde endDate” → morning-after — lanza stay_review survey |
survey.reminder_3d_due | daily_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-Key → domain_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 check —
GET /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étodo | Endpoint | Descripción |
|---|
| GET | /api/v1/notifications/automations | Listar flujos (envelope { object: "list", data, has_more, next_cursor }) |
| GET | /api/v1/notifications/automations/:id | Obtener un flujo |
| POST | /api/v1/notifications/automations | Crear flujo (acepta Idempotency-Key) |
| PUT | /api/v1/notifications/automations/:id | Actualizar flujo |
| POST | /api/v1/notifications/automations/:id/toggle | Activar/desactivar |
| POST | /api/v1/notifications/automations/:id/duplicate | Duplicar (acepta Idempotency-Key) |
| POST | /api/v1/notifications/automations/:id/validate | Validar contra el trigger sin guardar |
| POST | /api/v1/notifications/automations/:id/test | Test 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/:id | Eliminar |
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étodo | Endpoint | Descripció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/:id | Detalle (incluye payload completo) |
| POST | /api/v1/events/:id/replay | Reset status='pending' + retry_count=0 para reprocesar un evento failed |
Mantenimiento
Endpoints x-api-key-protegidos para crons de Windmill (no llamar manualmente):
| Método | Endpoint | Acción |
|---|
| POST | /api/v1/admin/maintenance/idempotency-keys/cleanup | Borra idempotency_keys con expires_at < now() |
| POST | /api/v1/admin/maintenance/domain-events/cleanup | Soft-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):
| Columna | Tipo | Descripción |
|---|
id | UUID | PK |
name | JSONB (i18n) | { es, en } |
description | JSONB (i18n) | Opcional |
trigger_event | string | Tipo de evento que dispara |
graph | JSONB | { nodes, edges } del builder |
is_active | boolean | Si está corriendo |
run_count | integer | Veces ejecutado |
last_run_at | timestamp | Última ejecución |
domain_events (vivla-tools Postgres):
| Columna | Tipo | Descripción |
|---|
id | UUID | PK |
event_id | UUID UNIQUE | Hash determinístico del header Idempotency-Key. Usado para dedupe a nivel DB. |
event_type | string | Tipo |
aggregate_id | string | Entidad principal |
aggregate_type | string | Categoría |
payload | JSONB | Datos del evento |
source | string | Quién lo emitió |
status | string | pending / published / failed |
retry_count, last_error | | Para 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):
scope='all' con el canal correspondiente bloquea todo el canal para ese user.
scope='category' matchea cuando scope_value === template.category.
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