> ## Documentation Index
> Fetch the complete documentation index at: https://wiki.vivla.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Automaciones

> Sistema event-driven para envío automático de notificaciones — builder visual + API pública para emitir eventos

# 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)

### Emitidos internamente por vivla-tools

**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](./events-api#eventos-descartados-no-emitir).

**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](./events-api).

### Request resumido

```http theme={null}
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](../api/idempotency).

### Response shapes

```json theme={null}
// 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](./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](../api/conventions) 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
```

<Note>
  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.
</Note>

## 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:

```sql theme={null}
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):

```sql theme={null}
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](./email).

## Referencias para integradores

* **Backend handoff completo** (para vivla-backend dev team): [`docs/internal/tools/notifications/implementation/epics/notification-automation-system/specs/backend-handoff.md`](https://github.com/vivla-tech/vivla-tools/blob/main/docs/internal/tools/notifications/implementation/epics/notification-automation-system/specs/backend-handoff.md)
* **PRD técnico v3.0**: [`docs/internal/tools/notifications/implementation/epics/notification-automation-system/02-prd-technical.md`](https://github.com/vivla-tech/vivla-tools/blob/main/docs/internal/tools/notifications/implementation/epics/notification-automation-system/02-prd-technical.md)
* **Catálogo de eventos**: [`docs/internal/tools/notifications/implementation/epics/notification-automation-system/03-events-catalog.md`](https://github.com/vivla-tech/vivla-tools/blob/main/docs/internal/tools/notifications/implementation/epics/notification-automation-system/03-events-catalog.md)
* **Schemas Zod**: `apps/backend/src/notifications/engine/schemas/event-schemas.ts`
