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:
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
- Windmill llama
POST /chat/sync/{entity} con x-api-key
- Backend crea un sync job en
chat_sync_jobs y ejecuta el sync
- Windmill poll
GET /chat/sync/jobs/:id cada 5 segundos hasta completion
- 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 |
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.
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.
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.
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
-- 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 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