Encuestas (Surveys)
El módulo de Encuestas es un sistema centralizado para gestionar encuestas dinámicas en Vivla. Permite crear encuestas con un builder visual, versionarlas, recopilar respuestas desde la app mobile, y analizar resultados — todo sin necesidad de cambios en código.Arquitectura
El backend es un módulo NestJS independiente (SurveysModule) registrado en AppModule. El frontend vive bajo la navegación del chat como parte del hub de customer happiness.
Tipos de encuesta
Motor de encuestas
Tipos de pregunta
Lógica condicional
Cada pregunta puede tenerconditionals: ConditionalRule[] — un array de reglas condicionales. Cada regla tiene:
- operator:
lt,lte,gt,gte,eq,neq,in - value: valor contra el que se compara la respuesta
- display:
inline(default) onext_screen— cómo la app renderiza las preguntas hijas - then:
Question[]— array de preguntas hijas mostradas cuando la condición se cumple
i18n
Cada texto es unI18nString = Record<string, string> (ej: {"es": "Texto", "en": "Text"}). La API acepta ?lang=es y devuelve strings resueltos. La app mobile recibe texto listo para mostrar.
Versionado
survey_types: agrupa versiones (ej: slughome-review)surveys: cada row es una versión con definición completa en JSONB- Flujo:
draft→active→archived - Partial unique index: máximo 1 versión activa por tipo
- Cambio de versión invalida respuestas parciales
Backend
Estructura de módulo
Backfill one-shot de nps-booking (book-review + stay-review)
El cron horario (sync-firebase-legacy-responses.ts → syncNpsBooking) solo mapea
los rounds onboarding, stay y home; el resto se descarta en silencio. El
comando standalone pnpm backfill:nps-booking-legacy trae los rounds restantes
una sola vez (no toca el sync regular ni el cron):
exchange/rent→ stay-review (filas sueltas, round reescrito astay, el round original preservado enresponse_data).open1/open2/round1/round2/1/2/open/booking→ book-review (NPS del proceso de reserva).--y rounds desconocidos → se saltan (loggeados).
(source_collection, source_doc_id) con source_doc_id = id crudo
de Firestore (sin prefijo paired::). Flags: --dry-run, --report-only,
--mode=book-review|stay-review|all, --rounds=csv. La lógica de mapeo vive en una
función pura testeable (cli/lib/map-nps-booking-legacy-doc.ts).
Identificadores de propiedad en las rutas mobile (homeId)
Las rutas mobile identifican la propiedad por su hid (properties.hid, el homes.old_id de vivla-api / Firebase): query param property= en GET /surveys/:slug, y scope.id en los POST de responses/complete/resume. Desde VIV — migración 241 — aceptan además un identificador alternativo homeId, el serial homes.id del panel (vivla-api) guardado en la nueva columna properties.panel_home_id:
GET /surveys/:slugacepta el query paramhomeId=.- Los
POSTde responses/complete/resume aceptanscope.homeId(entero) en el body.
homeId resuelve la propiedad vía properties.panel_home_id (en vez de hid) a través de SurveysService.resolvePanelHomeId. Es mutuamente excluyente con property/scopeId/scope.id: mandar los dos a la vez devuelve 400 (Provide either homeId or property/scopeId, not both). Enviar un serial dentro de property= sigue fallando (no hay sniffing: se busca como hid y no matchea).
La columna se crea vacía en la migración 241 y la rellena un CLI aparte (pnpm backfill:panel-home-id, leyendo homes.id/old_id del panel), no la migración. La respuesta de estado (survey-status) añade un campo homeId por scope junto a scope_id — aditivo, para que la app pueda migrar de hid cuando quiera; scope_id sigue siendo el UUID de Tools y results_url sigue keyed por hid.
Scope de reserva consciente del respondente (semanas cedidas, VIV-2505)
Una ofertato_rent/to_swap es la puesta en alquiler/intercambio del propietario, no una estancia — pero es el id que la app manda como scope.id (el oldBid de vivla-api v2) para una semana cedida. La estancia real del ocupante vive en una fila hija (rent/exchange) derivada. Por eso el scope de reserva se resuelve según quién pregunta: los cuatro caminos del controlador mobile (submitResponse, completeResponse, resumeResponse, getScopeStatuses) resuelven primero el respondente (resolveRespondent) y luego el scope, así que la resolución ya no corre en paralelo con la del scope.
Cuando el respondente no es el titular, el branch de booking redirige la oferta cedida (to_rent/to_swap, approval != rejected) a la fila hija del ocupante — primario por occupantUid de raw_data + documentId de la hija (childDocIdFromOfferRawData), con fallback por property_hid + fechas + user_id, desempatando por origin_booking_id (normalizando ceros a la izquierda). Así el ocupante ve su encuesta de estancia y su estado real (no completed), mientras el titular sigue viendo su propio scope vía el shim VIV-1994. Invariante: un scope por id de entrada, en el mismo orden, sin colapsar ni omitir (batcheado para evitar N+1).
Permisos
Los endpoints admin usanRequireTool('tool-chat', 'editor'). Los endpoints mobile usan MobileAuthGuard (POST) o son públicos (GET).
Respondientes externos (sin cuenta Vivla)
Una semana de alquiler/intercambio la puede ocupar un huésped sin cuenta Vivla. Para que pueda responder una encuesta de reserva, el módulosurveys/external/ expone dos endpoints públicos protegidos por ExternalSurveyTokenGuard (VIV-2476 D3):
A diferencia de
MobileAuthGuard (que confía en el userId que manda el cliente), el guard valida el token contra survey_tokens: debe existir, no estar expirado, no tener user_id (un token de usuario autenticado no puede reutilizarse por esta vía) y su survey_type_slug/scope_id deben coincidir con la ruta y el body. El scopeId que usa el handler sale siempre del token verificado, nunca del body.
La migración 237 hace nullable survey_tokens.user_id (con guest_name prellenado desde la reserva) y survey_responses.respondent_id, añadiendo respondent_name/respondent_email de texto libre. Reglas de producto: una sola respuesta externa por encuesta + reserva (índice parcial WHERE respondent_id IS NULL) y las respuestas externas cuentan en NPS/analítica sin marca especial (se distinguen por respondent_id IS NULL). Generar un token sin user_id solo se permite cuando scopeType = booking.
Frontend
Rutas
Componentes
API Client
Gamificación (Rewards)
- 1 reward point por encuesta completada (auto-award al completar)
- 5 niveles de rewards; al nivel 5: experiencia Vivla sorpresa
- Ciclo se reinicia tras completar nivel 5
- Toggle de recomendación + identificación de súper promotores (3+ recomendaciones)
Survey Scores (Métricas pre-computadas)
El sistema mantiene una tablasurvey_score_summaries con métricas headline pre-computadas por propiedad y usuario. Esto evita recalcular en cada request y permite mostrar scores en cards, tablas y dashboards.
Métricas por tipo de encuesta
Fuentes de datos
- PostgreSQL (
source: 'postgresql'): se recomputa automáticamente al completar una encuesta - Firebase (
source: 'firebase'): se sincroniza via CLI/Windmill (pnpm sync:firebase-scores) - Legacy individual (
survey_legacy_responses): respuestas individuales per-user migradas viapnpm sync:firebase-legacy-responses. Sync automático cada hora via Windmill (POST /chat/sync/firebase-legacy-responses)
Endpoints
Thresholds configurables
Cadasurvey_type tiene un campo result_config (JSONB) con umbrales configurables:
Fracciones de propiedad
La tablauser_properties almacena fractions (número de fracciones del deal) e is_vivla_property (boolean) para cada relación usuario-propiedad. Estos datos se populan automáticamente durante la sincronización de deals desde Firebase.
Filtrado de owners internos
En los dashboards de resultados (by-property y detalle de propiedad), los owners con emails@vivla.com se excluyen del cálculo de participación y scores — se consideran cuentas internas de prueba. Las fracciones restantes se asignan a un placeholder “Vivla Property”.
Excepción: carlos@vivla.com está en el allowlist (VIVLA_EMAIL_ALLOWLIST en get-property-owners.ts) y se trata como owner real.
Survey Insights (IA)
El dashboard de resultados del Home Review puede generar insights redactados por IA para cada sección, apoyados enAiService (ver Integración AI). Los insights se generan bajo demanda y se cachean en la tabla survey_insights.
Endpoint
Body (
GetInsightDto):
- section:
summary|percepcion|espacios|sensacion|esenciales(secciones del dashboard Home Review) - scopeType:
global(default) |property|user - scopeId: id de propiedad o usuario cuando el scope no es global
- filters:
{ dateFrom?, dateTo? }— recorta las respuestas consideradas
{ section, main, questions, generatedAt, model }, donde main es el resumen accionable de la sección y questions es un insight por questionId.
Caché y rate limit
- Caché por conteo de respuestas: cada fila se cachea por
(survey_id, scope_type, scope_id, section)junto altotal_responsescon el que se generó. Mientras el número de respuestas completadas no cambie, se sirve la caché; cuando llegan nuevas respuestas se regenera. - Rate limit: máximo 3 generaciones por día por combinación (survey, scope, section). Si se agota, se sirve la última versión cacheada (aunque esté algo desfasada); solo se salta el límite si aún no existe ninguna caché.
- Dedup thundering-herd: las peticiones concurrentes con la misma clave comparten la generación en vuelo.
- Regeneración: al guardarse una nueva respuesta, el
summaryse regenera en fire-and-forget (regenerateMainInsights).
El scope global usa el centinela
__global__ como scope_id (PostgreSQL NULL rompe el índice UNIQUE). Cada fila guarda model, input_tokens y output_tokens para trazabilidad de coste.Borradores de ticket (IA)
En/app/surveys/response/:id (superficie interna de CX), la IA descompone el comentario libre de una respuesta en N problemáticas y muestra cards de borrador de ticket — cada una enlaza a un ticket abierto ya existente de esa casa o abre el TicketCreationModal pre-rellenado. Alcance v1: stay-review y arrival-review (home-review es la superficie de Action Plans v2, fuera de esta feature).
Reglas de producto innegociables: la IA nunca auto-crea tickets (siempre gate humano en el modal) y nunca propone costes, importes, presupuestos, plazos ni compromisos de reparación — la lección de la reversión de “Action Plans v2” (migration
078_clear_action_plans.sql). Nada de esto se muestra al propietario; es 100% interno de CX.Disparo
- Automático: cuando la respuesta tiene comentario y
lowestNormalized < 8(el mismo corte decomputeSurveyScoringque dispara la alerta CX de Slack, F023) — el frontend genera solo al abrir la página (autoAnalyze: trueen la respuesta del GET). - Manual: botón “Analizar comentario” para el resto de respuestas.
- En ambos casos, la caché evita re-generar si el comentario no cambió.
Arquitectura de caché
Dos capas con vidas distintas:- Se cachea la descomposición del comentario (cara y estable — la respuesta es inmutable). Clave:
response_id(UNIQUE) +comment_hash(sha256 del comentario extraído) +prompt_version(constante en código). Sin TTL, sin contador de respuestas — a diferencia de Survey Insights, que invalida portotal_responses. - NO se cachea el estado del match: en la generación se guarda el snapshot de tickets abiertos que se le enseñó al LLM y el índice que casó; en cada lectura se re-verifica contra la DB que ese ticket sigue abierto (
chat_tickets.statusenOPEN_STATUSES) y no estáinvalid. Si se cerró, la card vuelve a “nuevo”. - Botón “Reanalizar” (
force: true) salta la caché y cuenta contra el rate limit (3 generaciones/día por respuesta — igual que Survey Insights). Si está limitado y hay fila cacheada (aunque el comentario haya cambiado desde entonces), se sirve constale: true.
Match sin alucinaciones
Al LLM se le pasa la lista de tickets abiertos de la casa numerada (máx. 25, más recientes primero) y devuelvematchedTicketIndex: number | null — nunca UUIDs. El backend traduce índice → id contra el snapshot que él mismo construyó; un índice fuera de rango se descarta, nunca se clampea.
Descartar borradores
Acción secundaria discreta por card (“Descartar”) para el borrador que CX mira y decide no convertir en ticket.discarded: boolean vive dentro de drafts_data.issues[i] en el mismo JSONB que createdTicketId — no hay columna ni migración nueva. Se persiste con POST .../:index/discard { discarded }, el endpoint hermano de .../link; reversible llamándolo de nuevo con discarded: false (“Deshacer”).
En el frontend, un borrador descartado sale de la lista principal y se cuenta en un pie plegable (“N descartados”) con “Deshacer” por fila; si se descartan todos, la lista principal muestra un mensaje en vez de quedar vacía. El botón “Descartar” se oculta una vez la card ya tiene un ticket real (createdTicket o un vínculo pendiente de reintento) — descartar algo que ya es un ticket no tiene sentido.
Traza ticket ← encuesta
Además del vínculo borrador→ticket endrafts_data (el que hace que la card no vuelva a ofrecer “Crear ticket”), el ticket creado guarda su propia procedencia en chat_tickets.metadata:
card → CopilotTicketDraft.metadata (opcional, aditivo) → TicketCreationModal → POST /chat/tickets. Los otros tres consumidores del modal (NewTicketButton, ChannelTickets, Conversation/Fabián) nunca rellenan este campo, así que su metadata sigue como antes. Objetivo: poder consultar desde el lado del ticket qué tickets nacieron de una encuesta, de qué casa y de qué respuesta — y que un agente (Alma/Concierge) pueda en el futuro responder “de tu comentario salieron estos tickets”.
Reparto LLM / determinista
Endpoints
Frontend
SurveyTicketDraftsSection se monta en SurveyResponseDetailPage (ambos layouts, new y legacy) y no renderiza nada cuando no hay comentario o la IA no está configurada (mismo criterio que AiInsightCard). El TicketCreationModal solo se monta (y pide sus propios dropdowns/property-destinations) cuando una card realmente abre “Crear ticket” — nunca en la carga de la página —, con initialDraft (campos de CopilotTicketDraft, incluyendo ticketKind) y sin channelId/channelDbId así el modal no pide su propio borrador a Fabián. Las cards traducen category/resolutionTeam a etiqueta legible con el mismo catálogo del modal (useTicketDropdownsQuery, cacheado por TanStack Query — no dispara una llamada extra).
Si guardar el vínculo borrador→ticket falla tras crear el ticket (POST .../:index/link), la sección reintenta un par de veces con backoff corto; si aun así falla, la card muestra el id del ticket ya creado y un botón de reintento manual — nunca “Ticket creado exitosamente” en silencio con el vínculo perdido (ese hueco habría permitido duplicar el ticket en la siguiente visita).
Enlace desde la alerta CX de Slack
La plantilla...0119 (alerta CX de survey.completed, ver AI · MCP) incluye un enlace Ver respuesta y crear tickets a /app/surveys/response/{{responseId}} — así CX llega desde Slack directo a las cards ya generadas (migration 178).
Mensaje sugerido al propietario (IA)
En la misma página (/app/surveys/response/:id), a la derecha de los borradores de ticket, SurveySuggestedMessageSection redacta un mensaje de acuse breve para el propietario a partir de su comentario — layout de 2 columnas: encuesta a la izquierda, mensaje sugerido + borradores de ticket en un rail de la derecha (colapsa a 1 columna en móvil, mismo orden).
Primera superficie del stack CX donde un servicio postea a un canal de cliente por iniciativa propia (Fabián solo redacta para que un humano revise-y-envíe; Lola responde dentro de una conversación ya existente). El envío sigue siempre gateado por un humano — ver la doctrina completa en Chat · Copiloto.
Arquitectura — cerebro vs. fontanería
Decisión explícita para no bloquear el día que un agente autónomo (Alma/Concierge) quiera hacer esto de punta a punta:- Cerebro (razonamiento LLM — redactar el texto): vive en Tools, prompt propio y aislado (
prompts/suggested-message-prompt.ts), reemplazable sin tocar el resto. - Fontanería (resolver destinatario, cascada de canal, chat-enabled, postear a Stream con identidad de marca, trazar): vive en Tools porque son queries a su propia DB + Stream, y se expone también como MCP tools (
resolve_survey_response_channel,send_owner_message— ver AI · MCP) para que un agente futuro la reutilice sin reimplementarla. Ambos caminos (REST y MCP) llaman al mismoSurveySuggestedMessageService— nunca hay dos implementaciones.
getMessage/generateMessage/send/resolveRecipientAndChannel) — nada dentro del servicio llama a send por su cuenta.
Dependencia de orden con los borradores de ticket
El mensaje describe el estado vivo de los borradores de ticket de esa respuesta (ya hay gestión en marcha / pendiente de revisión / nada accionable) — nunca los cuenta el LLM:summarizeTicketState calcula en código un resumen determinista (total/actioned/pending/discarded) y se lo pasa ya hecho al prompt. Ese resumen se persiste como basedOnTicketState junto al mensaje; en cada lectura se recalcula el estado vivo y, si difiere, la UI muestra un aviso (“el estado de los tickets cambió — regenera”) sin regenerar solo. Orden natural: revisar/crear tickets → Regenerar → enviar.
Precondición: generar el mensaje exige que ya exista fila en survey_ticket_drafts para esa respuesta (que el comentario se haya analizado al menos una vez en Borradores de ticket, aunque sea con 0 problemáticas) — la UI muestra una nota en vez del botón “Generar mensaje” mientras eso no haya pasado.
Cascada de canal
- Canal de la reserva del destinatario (
respondentId/clientUserId), si tiene chat habilitado (users.chat_enabled+chat_user_id) y el canal no estáarchived. - Si está archivado o no existe: canal de propiedad del owner para esa casa (
ChannelsService.getOwnerPropertyChannel, mismo patrón quegetNewsChannelForUser). - Ninguno de los dos:
hasChannel: false— se genera el mensaje igual, se oculta “Enviar” y se permite Copiar (para mandarlo por otra vía).
Remitente, idioma y tono
- Remitente: siempre la identidad de marca «Vivla» (
VIVLA_ADMIN_STREAM_USER_ID, la misma que ya usan los mensajes de sistema) — nunca un agente concreto, independientemente de quién dispare el envío (humano hoy, agente mañana). - Idioma: automático. No hay un campo fiable en DB (
users.languagelegacy está muerto) — el LLM detecta el idioma del comentario del propietario y redacta el mensaje entero en ese idioma; devuelvedetectedLanguage. - Matiz de apertura: el comentario se dejó en la encuesta, no en el chat — el mensaje abre/retoma la conversación por chat a raíz de la encuesta (“recibimos tu respuesta a la encuesta de tu estancia…”), nunca “gracias por tu mensaje” ni asume una conversación previa.
- Tono — mismas reglas duras que Borradores de ticket: acuse + acción, nunca importes, costes, plazos ni compromisos de reparación concretos (la lección de la reversión de “Action Plans v2”, migration
078). Máximo 4-5 frases; nunca menciona tecnicismos internos (ticket, borrador, IA).
Envío — siempre gate humano
POST .../suggested-message/send requiere tool-chat editor (leer/generar solo pide viewer) y es idempotente por sentAt — un segundo intento sobre la misma respuesta devuelve 409 sin reenviar. El frontend confirma con un AlertDialog que nombra destinatario + canal antes de llamar al endpoint; regenerar el mensaje limpia el sentAt de la fila (texto nuevo → vuelve a ser enviable). v1 es de solo lectura + copiar — sin edición inline del texto (posible follow-up).
Persistencia — migración 179
Columnasurvey_ticket_drafts.suggested_message (JSONB, migration 179_survey_suggested_message.sql) — misma fila que los borradores de ticket (una por response_id, migration 177) pero en su propia columna, no anidada en drafts_data: generateAndCache/updateIssueAt (Borradores de ticket) solo tocan drafts_data, así que reanalizar/link/discard nunca borran la auditoría de envío (sentAt/sentBy/channelUsed) de este mensaje. Rate limit propio (3 generaciones/día, contador en la misma columna) — no comparte contador con Borradores de ticket.
Endpoints
Frontend
SurveySuggestedMessageSection no renderiza nada sin comentario o con la IA no configurada (mismo criterio que Borradores de ticket). Estados: sin borradores analizados (nota, sin botón), no generado (botón “Generar mensaje”), generando (skeleton), listo (texto + badge de idioma + Regenerar + Copiar, siempre visibles), con canal (botón “Enviar a por ” tras confirmar) o sin canal (nota + solo Copiar), enviado (timestamp + enlace “ver conversación”, botón deshabilitado).
Reportes (tab “Reportes” / user-status)
La pestaña Reportes de la vista de resultados (/app/chat/surveys/results?tab=user-status) agrupa tres sub-pestañas:
Navegación por semana
En las dos pestañas de reporte semanal (weekly-report y weekly-list) el filtro de fechas global queda oculto y se sustituye por un stepper de semana (WeekNavigator, flechas ‹/›). La semana viaja en el search param weekOf (ISO yyyy-MM-dd dentro de la semana, default hoy). El stepper avanza ±7 días y deshabilita › si la semana siguiente sería futura.
Los bounds lunes→domingo se calculan en UTC en lib/surveys/week.ts (getWeekBounds), espejando el backend weekly-nps-compute.ts buildWindows: la semana reportada es la semana completa anterior a la que contiene weekOf (igual que el reporte semanal de email). El backend de weekly-nps ya acepta ?week=<ISO>; el frontend ahora lo envía.
Lista semanal reutiliza SurveysActivityPage en modo embedded con el rango de la semana como globalDateRange, así la tabla fila-por-respuesta y la expansión por tipo no se reimplementan. Encima añade el botón de export XLSX (hoja “Respuestas”: Fecha, Tipo, Usuario, Email, Casa, Destino, Nota Casa /10, Nota Equipo /10, Aprobación /10, Nota, Comentario). La fuente es GET /survey-responses (response-log); su MAX_LIMIT se subió a 500 para que una semana entre en una página (el export pagina defensivamente igualmente).
Notas /10
Todos los scores textuales del backoffice se muestran y etiquetan sobre 10 (sufijo ” / 10”) para consistencia, víaformatScore10 / normalizeTo10 (lib/surveys/score.ts). El hero “Valoración Media” de home-review (que internamente vive en escala 1–5) se normaliza a /10 y sus umbrales de color se rebasaron de 4/3 (escala /5) a 8/6 (escala /10). Los scores financial-review ya viven en 0–10 (no se re-normalizan, solo se etiquetan). Conteos y porcentajes no se etiquetan.
Fix — nombres ”—” en home-review › by-property
Al expandir una casa entab=home-review&subtab=by-property, los propietarios salían como ”—” cuando una propiedad cruzaba ~350 propietarios distintos en total. Causa: el PG path de getHomeReviewByProperty resolvía los nombres con un único .in('id', allOwnerIds); con ~400+ ids la URL de PostgREST (~15KB) la rechaza el gateway de Supabase con TypeError: fetch failed, y el código ignoraba el error → userMap vacío → userName: null. Fix: helper selectInChunks (results/select-in-chunks.ts) que trocea los .in() en chunks de 100, loguea (sin tragar) un chunk fallido y degrada parcialmente. Aplicado a los .in() no acotados del módulo (PG path, legacy path, buildEntityMaps).
Diseño visual — Escala NPS cromática
Todos los scores numéricos en el backoffice de encuestas usan el componente compartidoNpsScoreBadge — un badge cuadrado con la escala cromática NPS de 11 colores.
Escala NPS (0-1000)
Conversión
- NPS a estrellas:
stars = npsScore / 200 - Estrellas a NPS:
npsScore = stars * 200 - Los colores se interpolan entre paradas para gradientes suaves
- Texto auto-contraste (blanco sobre fondos oscuros, oscuro sobre fondos claros/amarillos)
Componente
Action Plans (Consenso)
Los planes de acción se generan automáticamente basados en consenso entre propietarios. Los umbrales son configurables viaresult_config en el survey type:
Vivla Property no vota en propuestas de mejora pero sí paga proporcionalmente según fracciones que posee. El coste se reparte por número de fracciones de cada propietario.
Flujo:
pending_review → approved → sent → archived
Exportación CSV
Todas las vistas del módulo de encuestas incluyen exportación a CSV enriquecida con datos completos (IDs + nombres legibles), BOM para compatibilidad con Excel, y quoting correcto.
La exportación del backend (
ResultsService.exportCsv) resuelve UUIDs de preguntas a texto legible, nombres de usuarios y propiedades desde sus tablas respectivas, y formatea respuestas (.value, .text, .selected, .approved) en lugar de JSON crudo.
Datos legacy individual (survey_legacy_responses)
Los datos individuales por usuario de Firebase se migran a PostgreSQL en la tabla survey_legacy_responses para habilitar tablas per-user, per-property y per-owner con filtrado.
Endpoints
Todos aceptan:
propertyId, userId, dateFrom, dateTo, page, limit, surveyId.
Sync CLI
--report-only genera un reporte en tools/scripts/output/ con:
- Usuarios no resueltos (enriquecidos con Firebase Auth: email, nombre, teléfono, estado)
- Propiedades no resueltas (nombre, dirección desde el doc de Firebase)
- Upserts fallidos con mensajes de error de Supabase
Windmill (sync automático)
El sync se ejecuta automáticamente cada hora via Windmill:- Windmill cron (
0 * * * *) → llamaPOST /chat/sync/firebase-legacy-responses - Backend crea sync job → ejecuta CLI script como child process
- Windmill polls
GET /chat/sync/jobs/:idhasta completion - Script Windmill:
docs/internal/tools/chat/implementation/epics/3.2-sync-module-improvements/scripts/sync_firebase_legacy_responses.ts
source_collection + source_doc_id) — ejecutar múltiples veces no duplica datos.
Datos legacy (Firestore — lectura directa)
Los resultados históricos también pueden leerse directamente de Firestore:- Lectura directa via
LegacyResultsService(para resultados detallados por propiedad) - Scores pre-computados via
sync-firebase-scores.tsCLI/Windmill (para métricas headline ensurvey_score_summaries)
Sync de scores legacy (agregados)
Sync API endpoints (para Windmill)
El Home Review v1 (“NPS Casa - Q1 2026”) fue insertado como
active via Migration 054 con 5 steps, 12 preguntas principales y ~24 preguntas hijas condicionales, con i18n completo (ES + EN), conditional_mode: "any" en espacios, y accepting_responses = true. El Financial Review fue importado via Migration 059 con 42 respuestas legacy CSV en escala 1-10.Home Excellence (Portal propietarios)
La appapps/home-excellence consume los endpoints públicos del módulo de surveys para mostrar resultados del Home Review 2026 a propietarios.
Endpoints públicos
Estos endpoints usan
@Public() y no requieren autenticación — el HID delimita el scope de datos.