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

# Windmill (Automatizaciones)

> Plataforma de automatización para syncs programados, webhooks y notificaciones — desplegada en Railway

# Windmill

Windmill es la plataforma central de automatizaciones de Vivla. Desplegada en Railway como instancia self-hosted, gestiona syncs programados, webhooks y notificaciones desde un único workspace.

## Arquitectura

```
┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Windmill    │────▶│  Vivla Tools API │────▶│  Supabase    │
│  (Railway)   │     │  (NestJS)        │     │  (PostgreSQL)│
└──────────────┘     └──────────────────┘     └──────────────┘
       │                     │
       │              ┌──────────────┐
       │              │  Firebase    │
       │              │  (Firestore) │
       │              └──────────────┘
       │
       ▼
┌──────────────┐
│  Slack       │
│  (alertas)   │
└──────────────┘
```

## Workspace

Un único workspace `vivla` organizado por proyecto:

| Carpeta                        | Proyecto      | Contenido                                                                 |
| ------------------------------ | ------------- | ------------------------------------------------------------------------- |
| `f/vivla_tools/sync/`          | Vivla Tools   | Sync jobs (users, properties, deals, bookings, firebase-legacy-responses) |
| `f/vivla_tools/notifications/` | Vivla Tools   | Reemplazo de Firebase Cloud Functions                                     |
| `f/vivla_tools/webhooks/`      | Vivla Tools   | Handlers de eventos externos                                              |
| `f/vivla_backend/`             | vivla-backend | Automaciones de eventos y migraciones                                     |
| `f/shared/`                    | Compartido    | Utilidades cross-project (HTTP clients)                                   |

## Variables de entorno

Los scripts reciben un parámetro `env` (`'dev'` o `'prod'`) y resuelven variables con el sufijo correspondiente:

```typescript theme={null}
const suffix = env === 'prod' ? '_prod' : '_dev'
const apiUrl = await wmill.getVariable(`f/vivla_tools/api_url${suffix}`)
const apiKey = await wmill.getVariable(`f/vivla_tools/api_key${suffix}`)
```

| Variable dev                | Variable prod                | Secret |
| --------------------------- | ---------------------------- | ------ |
| `f/vivla_tools/api_url_dev` | `f/vivla_tools/api_url_prod` | No     |
| `f/vivla_tools/api_key_dev` | `f/vivla_tools/api_key_prod` | Si     |

## Autenticación

Los scripts se autentican contra la API con `x-api-key` header. El backend valida el key via `Auth0OrApiKeyGuard` — el mismo guard que acepta tokens Auth0 para usuarios humanos.

No requiere Auth0 M2M, no expira, no necesita refresh.

## Sync Jobs

### Endpoints

| Endpoint                               | Método | Descripción                                                                     |
| -------------------------------------- | ------ | ------------------------------------------------------------------------------- |
| `/chat/sync/users`                     | POST   | Sync usuarios desde Firebase. Body: `{ chainDealsSync?: boolean }`              |
| `/chat/sync/properties`                | POST   | Sync propiedades                                                                |
| `/chat/sync/deals`                     | POST   | Sync deals                                                                      |
| `/chat/sync/bookings`                  | POST   | Sync bookings                                                                   |
| `/chat/sync/firebase-legacy-responses` | POST   | Sync respuestas NPS legacy (3 colecciones Firebase → `survey_legacy_responses`) |
| `/chat/sync/jobs/:id`                  | GET    | Estado del job (incluye `errorMessage` y `steps` en caso de fallo)              |
| `/chat/sync/jobs/:id/logs`             | GET    | Logs detallados del job                                                         |
| `/chat/sync/jobs`                      | GET    | Listar jobs recientes                                                           |

### Flujo de ejecución

1. Windmill llama `POST /chat/sync/{entity}` con `x-api-key`
2. Backend crea un sync job en `chat_sync_jobs` y ejecuta el sync
3. Windmill poll `GET /chat/sync/jobs/:id` cada 5 segundos hasta completion
4. En caso de fallo, el error se guarda en `error_message` + `chat_sync_logs`

## Flows programados

### Daily Full Sync

Sync completo diario de todas las entidades:

* **Path:** `f/vivla_tools/sync/sync_full`
* **Schedule:** `0 4 * * *` (4:00 AM UTC)
* **Retry:** 2 reintentos, 5 minutos de backoff

| Step | Script                           | Input                                           |
| ---- | -------------------------------- | ----------------------------------------------- |
| 1    | `sync_users`                     | `env = flow_input.env`, `chainDealsSync = true` |
| 2    | `sync_properties`                | `env = flow_input.env`                          |
| 3    | `sync_bookings`                  | `env = flow_input.env`                          |
| 4    | `sync_firebase_legacy_responses` | `env = flow_input.env`                          |
| 5    | `slack_notify`                   | Resultado de steps anteriores                   |

<Warning>
  **Cada step debe tener `env` conectado a `flow_input.env`.** Si no se conecta, el step usa el default `'dev'` aunque el schedule pase `'prod'`. Esta es la misconfiguration más común — si el sync prod parece no funcionar, verificar primero este wiring.
</Warning>

<Note>
  **`sync_properties` reconcilia el `status` operativo desde la fuente de verdad.** El `status` lo toma del **enum limpio de la v2 Postgres de vivla-api** (`homes.status` ∈ `inactive`/`pre-active`/`active`), que vivla-api normaliza cada hora desde el `homes.status` crudo de Firestore (que mezcla strings sucios como `secured`/`excellent`/`renovation`) y que el panel admin escribe directo. El sync trae los campos ricos de Firebase y solo superpone `status`/`test_home` desde la v2 por `hid` (`applyV2StatusOverlay`); una casa ausente en la v2 conserva su status de Firebase (mapeado por `mapFirebaseHomeStatusToToolsStatus`: `inactive`/test→`inactive`, resto→`active`). Si la v2 Postgres no está disponible, degrada al status de Firebase. En tools: `inactive`/test → `inactive`; el resto → `active`. Antes el sync nunca tocaba `status`, así que las desactivaciones del origen no se propagaban y `properties` acumulaba casas `active` obsoletas.
</Note>

<Note>
  **`sync_properties` también sincroniza la agente de chat responsable por propiedad desde la v2 (fuente de verdad).** Como paso final trae `homes.cx_agent → cx_agents.email` de la v2 (en el mismo overlay que el `status`) y reconcilia `property_agent_assignments` (`assignment_type = 'chat_agent'`, la asignación que usa la creación de canales para poner la agente por defecto): mapea el email v2 a un usuario de tools (case-insensitive) que sea `chat_role` `admin`/`moderator` y crea o **reemplaza** la asignación manual. Si la casa no tiene `cx_agent` en la v2, el email no mapea a un usuario elegible, o la casa no existe en tools, la asignación existente **no se toca** (se cuenta como `skipped_no_match` en los logs del job). **Editar la agente a mano en Settings → Propiedades es efímero: el siguiente `sync_properties` la sobrescribe con el valor de la v2.**
</Note>

### NPS Legacy Responses (Hourly)

Sync independiente de respuestas NPS legacy:

* **Path:** `f/vivla_tools/sync/sync_firebase_legacy_responses`
* **Schedule:** `0 * * * *` (cada hora)
* **Colecciones:** `nps-home-responses`, `nps-booking`, `nps-home-excellence-values`

El sync es idempotente (upsert en `source_collection + source_doc_id`).

## Slack Notifications

El último step del flow envía un resumen a Slack usando Block Kit:

* **Canal:** `#vivla-tools-sync`
* **Resource:** `slack_auth` (tipo RT.Slack configurado en Windmill)

## Scripts de referencia

Todos los scripts están en el repo como referencia:

```
docs/internal/tools/chat/implementation/epics/3.2-sync-module-improvements/scripts/
  sync_users.ts
  sync_properties.ts
  sync_deals.ts
  sync_bookings.ts
  sync_firebase_legacy_responses.ts
  sync_full.ts
  slack_notify.ts
```

## Sync interino Webflow → módulo web

Mientras Webflow siga siendo la fuente de la verdad del listado público de casas
(hasta la migración final de la web), un job diario mantiene el módulo web en
paridad para que `GET /api/v1/web/properties` sirva lo mismo que Webflow
(consumidor downstream: vivla-api → MyInvestor).

* **Path:** `f/vivla_tools/sync/sync_web_webflow` (snapshot en `apps/windmill/f/vivla_tools/sync/`)
* **Schedule:** `0 0 7 * * *` diario 07:00 Europe/Madrid, args `env=prod`
* **Matching de casas existentes:** primero por `slug`; si falla, por **nombre
  normalizado** (lowercase + sin diacríticos + whitespace colapsado). Webflow **no
  regenera el slug al renombrar** una casa, así que tras un rename el item llega
  con el slug viejo y el name nuevo (p.ej. `casa-ribadesella` → name "Casa Xuncu",
  ya existente en tools como `casa-xuncu`). Esos casos se resuelven por nombre,
  salen en el resultado como `slug_aliases` (`{webflow_slug, tools_slug, name}`) y
  **no** cuentan como `extra_in_tools`. Sólo un item sin match por slug **ni** por
  nombre se considera alta.
* **Qué escribe en casas existentes** (por slug o por nombre — **Webflow gana en
  todo**, es la fuente que ve el cliente):
  * `property_web_data`: `web_title` (nombre público del feed `title`, ← `name`,
    i18n en/es/fr, compara normalizado), `status_web` (issold→`sold_out`,
    ishiden→`hidden`, iscomingsoon→`coming_soon` (slug real sin guiones), resto→`listed`), `price_from`
    (`fraction-price`), `location_text` (i18n = texto Webflow) y `description`
    (HTML Webflow → Lexical i18n; sólo si el texto plano normalizado difiere).
  * `properties`: `latitude/longitude` (`coordinates`), `address` y
    `bedrooms/bathrooms/size` (← `bedrooms`/`bathrooms`/`sqm`). **No** toca
    `properties.name` (ése lo ownea el feed operativo).
  * `property_videos`: vídeo **hero** visible ← `fieldData.video.url`. Si la
    property no tiene vídeo visible → INSERT; si lo tiene → PATCH del hero (o el
    de menor `sort_order`) cuando la URL difiere. Nunca borra: si Webflow no trae
    vídeo, no toca nada.
* **El baile con el sync operativo nocturno:** `enrichPropertiesFromV2`
  (`src/chat/sync/sync.service.ts`, dentro del `sync_full` de \~06:00 Madrid)
  revierte `bedrooms/bathrooms/size` con el valor de vivla-api (v2) en las casas
  con `hid`; este job, a las **07:00**, los re-aplica desde Webflow. Durante el
  día laborable el feed sirve los valores de Webflow; entre \~06:00 y 07:00 puede
  haber una ventana con valores v2. Es interino y desaparece con la migración
  final. `address` y `latitude/longitude` no los toca el nocturno.
* **Altas automáticas (v2):** crea las casas de Webflow sin match por slug ni por
  nombre — `properties` (status `inactive`, conservador: el sync operativo lo corrige si
  la casa existe en su fuente) + `property_web_data` + `property_photos`
  (imágenes subidas a **Cloudinary** con upload firmado, carpeta
  `web/properties/<slug>`; si un upload falla, cae a la URL de Webflow como
  fallback) + `property_videos` + `property_web_amenities`. Las casas hidden se
  crean con `status_web='hidden'`; las draft/archived de Webflow no se crean. **No
  crea amenities nuevas** (sólo enlaza las que ya existen por slug; un slug sin
  match sale como `warning`). **Fail-safe:** más de 10 altas en un run se
  interpreta como respuesta anómala de Webflow y aborta sin escribir. Las altas
  salen en el resultado (`created[]`) y avisan por Slack; `missing_in_tools` pasa
  a contener sólo las altas que fallaron.
* **Qué NO hace:** no borra ni oculta filas de tools ausentes en Webflow
  (`extra_in_tools`, report-only), no toca `properties.name` (feed operativo) ni
  borra vídeos.
* **Excepción al patrón thin-trigger:** escribe directo a Supabase vía PostgREST
  (variables `f/vivla_tools/supabase_*` + `f/vivla_tools/webflow_token` +
  `f/vivla_tools/cloudinary_*` para las fotos de las altas) porque es tooling
  interino que se retira al completar la migración; soporta `dry_run` (las altas
  se reportan con `would_create` sin subir a Cloudinary ni escribir).

## Debugging

### Ver error de un sync job fallido

```sql theme={null}
-- En Supabase SQL Editor
SELECT id, status, error_message, steps, result
FROM chat_sync_jobs
WHERE id = '<job-id>';

-- Logs detallados
SELECT level, message, context, created_at
FROM chat_sync_logs
WHERE job_id = '<job-id>'
ORDER BY created_at DESC;
```

### Errores comunes

| Error                     | Causa                                                                | Solución                                                             |
| ------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `MODULE_NOT_FOUND` en CLI | El backend en prod ejecuta JS compilado pero el spawn apunta a `.ts` | Verificar que `sync.service.ts` detecta `dist/` y usa `node` + `.js` |
| `variable not found`      | Variable de Windmill no existe o tiene path incorrecto               | Verificar sufijo `_dev`/`_prod` y el path exacto con `f/` prefix     |
| `401 Unauthorized`        | API key no coincide con la del backend                               | Comparar variable de Windmill con `API_KEY` en Railway               |
| Job `failed` sin detalle  | Script Windmill no lee `errorMessage` del job                        | Actualizar script para incluir `errorMessage` y `steps` en el throw  |
| Prod sync hitting dev     | `env` no conectado a `flow_input.env`                                | Abrir flow editor, conectar cada step a `flow_input.env`             |

## Notification automation engine

Además de los sync jobs, Windmill orquesta el procesamiento de eventos de dominio del sistema de notificaciones (epic notification-automation-system). Estos scripts viven en `apps/windmill/src/`:

### Cron consumer

* **`process_domain_events.ts`** (cada 30 s) — lee `domain_events WHERE status='pending'`, ejecuta los flows que matchean cada `event_type`, marca como `published` o `failed` con retry. Es el único consumer del pipeline.

### Cron emitters (eventos derivados)

Reemplazan el patrón antiguo de "delay" en el builder. Cada cron consulta vivla-backend (read-only) o vivla-tools, detecta candidatos, y POSTea a `/api/public/events/ingest` con el evento derivado. Idempotent vía `Idempotency-Key` determinística por día.

| Script                                   | Schedule (TZ Madrid) | Evento emitido               |
| ---------------------------------------- | -------------------- | ---------------------------- |
| `cron-emitters/daily_pre_stay_30d.ts`    | Diario 09:00         | `booking.pre_stay_30d_due`   |
| `cron-emitters/daily_pre_stay_3d.ts`     | Diario 09:00         | `booking.pre_stay_3d_due`    |
| `cron-emitters/daily_arrival_review.ts`  | Diario 10:00         | `booking.arrival_review_due` |
| `cron-emitters/daily_stay_review.ts`     | Diario 10:00         | `booking.stay_review_due`    |
| `cron-emitters/daily_survey_reminder.ts` | Diario 09:00         | `survey.reminder_3d_due`     |
| `cron-emitters/user_lifecycle.ts`        | Diario 10:00         | `user.inactive` (60d)        |

### Crons de mantenimiento

| Script                        | Schedule     | Endpoint llamado                                        |
| ----------------------------- | ------------ | ------------------------------------------------------- |
| `cleanup_idempotency_keys.ts` | Diario 03:00 | `POST /v1/admin/maintenance/idempotency-keys/cleanup`   |
| `cleanup_domain_events.ts`    | Diario 03:30 | `POST /v1/admin/maintenance/domain-events/cleanup`      |
| `health_check.ts`             | Cada 5 min   | Verifica `domain_events` pending > 5 min — alerta Slack |

### Variables adicionales para el engine

| Variable                                     | Uso                                                    |
| -------------------------------------------- | ------------------------------------------------------ |
| `f/vivla_tools/api_url_{dev,prod}`           | base URL de vivla-tools (`https://tools.vivla.com`)    |
| `f/vivla_tools/api_key_{dev,prod}`           | `NOTIFICATIONS_API_KEY` para `x-api-key` header        |
| `f/vivla_tools/slack_webhook_url_{dev,prod}` | webhook para alertas (errores en crons, eventos stuck) |

## Deploy desde GitHub (CI)

El workspace de Windmill se sincroniza desde el repo via [`wmill` CLI](https://www.windmill.dev/docs/advanced/cli) en lugar de drag-and-drop manual. El workflow de GitHub Actions vive en `.github/workflows/windmill-deploy.yml`:

* **Trigger**: merge a `main` o `develop` que toque `apps/windmill/**`. También `workflow_dispatch` manual con flag `apply` / `dry-run`.
* **`develop`** → dry-run (muestra el diff, no aplica).
* **`main`** → `wmill sync push --yes` (aplica).
* **Secrets requeridos** en GitHub Settings → Secrets and variables → Actions → "Repository secrets":
  * `WMILL_TOKEN` (token con scope write)
  * `WMILL_WORKSPACE_ID` (ej. `vivla`)
  * `WMILL_BASE_URL` (URL del Windmill self-hosted)

**Lo que sí se sincroniza**: scripts (`.ts`), flows, schedules, resource types — todo lo que esté en `apps/windmill/`.

**Lo que NO se sincroniza** (configurar a mano una vez en la UI):

* Variables (las del cuadro de arriba) — secretos no se suben por CI.
* Resources (conexiones a Postgres, Slack, etc.) si se usan, normalmente no es nuestro caso.
* Permisos del workspace.

## Documentación técnica detallada

Para guías paso a paso de configuración del workspace:

* `docs/internal/tools/chat/implementation/epics/3.2-sync-module-improvements/02-windmill-setup-guide.md`
* `docs/internal/tools/chat/implementation/epics/3.2-sync-module-improvements/03-windmill-configuration-walkthrough.md`
* Notification automation specs: `docs/internal/tools/notifications/implementation/epics/notification-automation-system/specs/windmill-crons.md`
