Plan: feedback de usuarios con perfil, persistencia y respuestas¶
Estado: implementado (decisión 39, PR #11, mergeado)
Queda como documento de trabajo original — las fases describen lo que se pidió ejecutar, no siempre lo que terminó construido al pie de la letra. Diferencias reales con lo planeado, verificadas contra el código:
- Fase 1 (migración): no quedó como runbook manual de
psql—0001_feedback.sqlse aplica on-boot víaserver/plugins/migrate-feedback.ts(SQL embebido en el plugin, no leído de disco: el contenedor de producción solo empaqueta.output/). Mantenerlo en sync manual con el.sqlversionado es responsabilidad de quien toque el schema. Razón y decisión completa en decisión 39. - Fase 4 (
feedbackMachine): quedó en cuatro regiones paralelas (identity,dialog,form,thread), no tres — el diálogo abierto/cerrado se separó deformen vez de vivir dentro; no hay regiónnudgeaparte, esa política vive dentro deidentity. Ver Máquinas de estado (añadido ahí; faltaba en el commit original, contra la convención del propioCLAUDE.md). - Fase 5 (
FeedbackDialog.vue): el aviso de queTabModal.vueya no existe (nota dejada en la Fase 5 de abajo) sigue siendo correcto — la lógica de tabs se copió inline deDataModal.vue, como anticipaba el aviso. - Fase 7 (documentación):
docs/feedback.md(schema/GRANTs/env vars/runbook) nunca se creó — esa referencia no existe en el repo; lo más cercano es el resumen enCLAUDE.md.runtimeConfig.public.buildIdsí se añadió anuxt.config.ts, pero elARG GIT_SHAdel Dockerfile planeado en la Fase 3/5 no se añadió — el build de producción no pasaGIT_SHA, así quebuildIdqueda siempre en'dev'en el contexto automático adjunto al feedback. Pendiente si se quiere que el campo sirva para algo.
Actualización 2026-10-11 (decisión 50): la bandeja dejó de ser de un solo producto.
lamula-process escribe en la misma viewer.feedback y la columna app
('webviewer' | 'process', migración 0002_app.sql) dice de cuál viene cada mensaje; el
admin filtra por ella. Eso trajo el primer CORS del repo
(server/middleware/cors.ts + server/utils/cors.ts), acotado a /api/feedback**.
El plan del lado cliente vive en el otro repo: docs/plan-feedback.md de lamula-process.
No re-litigar las decisiones de diseño sin motivo — siguen vigentes, igual que en Decisiones de diseño.
Contexto¶
El viewer es un demo que se enseña a profesores, meteorólogos de centros, investigadores y aficionados. Hoy no hay forma de que ninguno deje una opinión: lo que se aprende del uso real se pierde. Hace falta (a) recoger feedback dentro de la app, (b) saber a qué se dedica quien lo deja, para pesar cada sugerencia por perfil, (c) guardarlo en base de datos, no solo en el navegador, y (d) poder responder a cada recomendación y que el usuario vea la respuesta al volver. Un pill flotante da el acceso permanente; un popup ocasional (no insistente) lo recuerda a quien no lo vio.
Esto introduce la primera escritura a base de datos del proyecto. Hoy no existe ninguna:
cero *.post.ts, cero readBody, Dal sin métodos mutadores, y server/dal/live.ts:1 lleva
escrito "solo SELECT — decisión 17".
Decisión de arquitectura (nueva, D39 — primer número libre en docs/decisiones.md)¶
La regla "jamás escribe en Postgres" existe para proteger el contrato del pipeline, no para prohibir que el viewer tenga datos propios. Se acota, no se rompe:
- El schema
public(tablas del pipeline) sigue siendo soloSELECT, y ahora lo hace cumplir la base de datos, no una convención: el rol actual no recibe permisos de escritura. - El feedback vive en un schema nuevo
viewer, propiedad de ESTE repo, con sus migraciones endb/viewer_migrations/y un rol propio con CRUD solo enviewer. scripts/check-contract-drift.shytests/contract/schema/0001_init.sqlno se tocan: siguen vigilando solo el contrato del pipeline.FeedbackDales una interfaz aparte deDal.tests/unit/dal.spec.ts(puerta M1) y su test de paridad quedan intactos.
Fase 1 — Schema y permisos¶
db/viewer_migrations/0001_feedback.sql (nuevo directorio, se aplica a mano en el despliegue):
CREATE SCHEMA IF NOT EXISTS viewer;
CREATE TABLE IF NOT EXISTS viewer.feedback_users (
token_hash TEXT PRIMARY KEY, -- sha256 hex del token del navegador
role TEXT NOT NULL CHECK (role IN
('profesor','meteorologo','investigador','estudiante','aficionado','otro')),
role_other TEXT,
display_name TEXT, email TEXT, organization TEXT, country TEXT, locale TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS viewer.feedback (
id BIGSERIAL PRIMARY KEY,
token_hash TEXT NOT NULL REFERENCES viewer.feedback_users(token_hash) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('mejora','bug','dato','otro')),
rating SMALLINT CHECK (rating BETWEEN 1 AND 5),
message TEXT NOT NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb, -- captura automática del mapa
status TEXT NOT NULL DEFAULT 'nuevo' CHECK (status IN
('nuevo','leido','respondido','cerrado')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_feedback_user ON viewer.feedback (token_hash, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_feedback_triage ON viewer.feedback (status, created_at DESC);
CREATE TABLE IF NOT EXISTS viewer.feedback_replies (
id BIGSERIAL PRIMARY KEY,
feedback_id BIGINT NOT NULL REFERENCES viewer.feedback(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'LAMULA',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ -- NULL = pendiente → badge en el pill
);
CREATE INDEX IF NOT EXISTS idx_reply_feedback ON viewer.feedback_replies (feedback_id, created_at);
Identidad sin cuentas. El navegador genera un token aleatorio de 32 bytes
(crypto.getRandomValues → base64url) y lo guarda en localStorage. El servidor solo almacena
sha256(token). El token viaja en la cabecera x-feedback-token, nunca en la query string
(no acaba en logs ni en Referer). Sin token no se puede leer el hilo de otro. Un UUID "público"
como identificador sería adivinable/enumerable: por eso token secreto + hash, no id.
GRANTs (a ejecutar una vez en el VPS, documentar en docs/feedback.md):
CREATE ROLE viewer_rw LOGIN PASSWORD '…';
GRANT USAGE ON SCHEMA viewer TO viewer_rw;
GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA viewer TO viewer_rw;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA viewer TO viewer_rw;
-- y NO se le concede nada sobre public
Fase 2 — Contrato y DAL de feedback¶
shared/contract/feedback.ts(nuevo), exportado desde el barrelshared/contract/index.ts:1. Misma convención queshared/contract/schemas.ts: tuplasas const+ tipo derivado (FEEDBACK_ROLES,FEEDBACK_KINDS,FEEDBACK_STATUSES) y esquemaszFeedbackProfile,zFeedbackSubmission,zFeedbackContext,zFeedbackThread,zAdminReply. Los enums Zod se construyen desde las tuplas, comoz.enum(WIND_LEVELS)enshared/contract/schemas.ts.server/dal/feedback-types.ts: interfazFeedbackDal—upsertUser,createFeedback,listMine(tokenHash),markRepliesRead(tokenHash),listAll(filters),addReply,setStatus.server/dal/feedback-live.ts: implementación sobrePgLike(server/dal/types.ts:61), placeholders$1,$2,…,INSERT … RETURNING id, upsert conON CONFLICT (token_hash) DO UPDATE.server/dal/feedback-memory.ts: adaptador paraNUXT_DAL_ADAPTER=fixture—Mapen memoria, volátil. Es lo que permite que el e2e ejercite el flujo completo sin Postgres. (Grabar feedback real enserver/dal/fixtures/no tiene sentido: es dato de escritura, no una grabación.)server/dal/feedback.ts: factoryuseFeedbackDal(event), espejo deuseDal(server/dal/index.ts:14-39), con la misma degradación a 503 con mensaje explícito si falta config.
Refactor obligatorio en server/dal/pg.ts:17. Hoy el cliente es un singleton con ??= que
ignora la config después de la primera llamada. Con dos roles (lectura en public, escritura
en viewer) eso devolvería la conexión equivocada. Cambiar a un Map cacheada por
${host}:${port}/${db}@${user}. Nuevas env vars en runtimeConfig (nuxt.config.ts:80-91):
pgWriteUser, pgWritePassword (si están vacías, el feedback queda deshabilitado y los
endpoints responden 503 — el viewer sigue funcionando entero).
Fase 3 — Endpoints¶
Patrón idéntico al existente (server/api/wind/times.get.ts): parseQueryParams /
readValidatedBody con Zod, sin try/catch, createError para los fallos.
| Ruta | Qué hace |
|---|---|
POST /api/feedback |
Verifica Turnstile, rate-limit, upsert de perfil, inserta feedback, dispara webhook. 201 |
GET /api/feedback/mine |
Cabecera x-feedback-token → hilos con respuestas. Cache-Control: no-store |
POST /api/feedback/mine/read |
Marca read_at de las respuestas del token (un GET no debe mutar) |
GET /api/admin/feedback |
Lista con filtros status/role, paginada |
POST /api/admin/feedback/[id]/reply |
Inserta respuesta y pasa status a respondido |
POST /api/admin/feedback/[id]/status |
Cambia estado (leido/cerrado) |
POST /api/admin/login |
Compara con NUXT_ADMIN_TOKEN usando timingSafeEqual, setea cookie httpOnly+secure+SameSite=Lax |
server/middleware/admin.ts (nuevo, primer middleware del repo): protege /api/admin/** y
devuelve 401 sin cookie válida.
Validación de body nueva en el repo. server/dal/params.ts:5 solo valida query. Añadir ahí
mismo un parseBody(event, schema) hermano, con el mismo formato de error 400 por campo
(params.ts:8), para no inventar un segundo estilo.
Turnstile (skill turnstile-spin disponible). NUXT_PUBLIC_TURNSTILE_SITE_KEY +
NUXT_TURNSTILE_SECRET. Sin site key el widget no se monta y el servidor no verifica — eso es lo
que mantiene el e2e determinista y el dev offline. Falla cerrado: en producción
(NUXT_DAL_ADAPTER distinto de fixture) sin secreto configurado, POST /api/feedback responde
503 en vez de aceptar sin captcha; el bypass exige NUXT_TURNSTILE_DISABLED=1 explícito.
Rate-limit en memoria por IP y por token (p. ej. 5/hora): un solo contenedor, se pierde al
reiniciar — suficiente, y no añade Redis.
Actualización (D51). Lo de arriba sigue valiendo para el webviewer.
lamula-processqueda exento del captcha (CAPTCHA_EXEMPT_APPSenserver/utils/feedback-policy.ts) porque es una app offline-first y el widget sería su única dependencia de red externa. Ese camino lleva un cubo de rate-limit extra por IP (noCaptcha:${ip}, 30/h), yresolveClientIppasó a usarcf-connecting-ip/ el último elemento dex-forwarded-for— el primer elemento de XFF lo controla quien llama y servía para estrenar cubo en cada envío.
Aviso de feedback nuevo. NUXT_FEEDBACK_WEBHOOK_URL: un POST JSON genérico, fire-and-forget,
cuyo fallo nunca rompe el envío del usuario. Sirve tal cual para la API de Telegram, un webhook
de Slack/Discord o ntfy, sin código por proveedor ni decidir credenciales ahora. Asunción a
confirmar: no hay canal de alertas preexistente que reutilizar. No se envía email al usuario
cuando respondes: la respuesta se lee en la app (badge).
Fase 4 — Estado de cliente¶
composables/useFeedbackIdentity.ts, calcado de composables/useViewerPrefs.ts (funciones puras,
guarda typeof localStorage === 'undefined', try/catch silencioso, campo v versionado con
migración en memoria). Clave propia lamula:feedback, no dentro de lamula:prefs — las prefs
son ajustes de display con su propia escalera de versiones v1→v5; mezclarlas la ensucia.
{ v: 1, token: string, profile?: FeedbackProfile,
nudge: { shownCount: number, snoozeUntil: number | null, submitted: boolean } }
machines/feedback.ts (XState v5, decisión 18/27): máquina propia, orquestada por la página
con watchers, igual que overlayMachine — tiene ciclo de vida propio (identidad, envío, hilo) y
no debe reiniciarse con el del raster. Regiones paralelas identity / form / thread / nudge.
Siguiendo la lección anotada en machines/overlay.ts:16-23: ningún evento en la raíz, todo a
nivel de región. El estado no va a la URL (no es compartible — mismo criterio que la D28 para
las prefs).
utils/feedback/nudge.ts: política pura con reloj inyectado (mismo patrón que
utils/lightning/anim.ts), así los tests son deterministas sin tocar el reloj:
- No aparece nunca antes de 90 s de uso real de la sesión (no al primer paint: pedir opinión antes de que la app haya dado valor es justo lo "pesado" que hay que evitar).
- Máximo 3 veces en la vida del navegador; "más tarde" pospone 3 días, "no, gracias" 7 días.
- Tras enviar feedback no vuelve a aparecer jamás. El pill se queda siempre.
Fase 5 — UI¶
- Pill: una pastilla más en el rail derecho de
LayersMenu.vue:86, con las clases de pastilla ya canónicas (LayersMenu.vue:102) y emitiendoopen-feedbackhacia la página, exactamente como ya hacenopen-panel/open-prefs(LayersMenu.vue:64-72). Es el único sitio libre: arriba-izq estáRadarProductChipy abajo el timebar ocupa todo el ancho (pages/…/[[time]].vue:816). Badge: punto rojo cuando hay respuestas conread_at IS NULL. components/FeedbackDialog.vue:<dialog>nativo, patrón dePrefsDialog.vue:19-23—defineExpose({ open }),<form method="dialog">,aria-labelledby. No un dock:DataModalyLayersMenuno tienen ni Esc ni focus trap (DataModal.vue:2-5), y un formulario los necesita. Dos tabs: Enviar y Mis mensajes (hilo con tus respuestas). Campos: ocupación (obligatoria,<select>+ "otro" en texto), tipo, valoración 1-5 opcional, mensaje (obligatorio), y plegado "sobre ti" opcional con nombre/email/institución/país. El perfil se precarga delocalStorage: a partir del segundo envío solo se escribe el mensaje.Ojo:
TabModal.vueya no existe (borrado enad064dc, pese a lo que dicedocs/decisiones.md:132). La lógica de tabs se copia deDataModal.vue:48-62.components/FeedbackNudge.vue: tarjeta pequeña abajo-derecha (absolute bottom-28 right-4 md:right-8 z-30) — esquina libre desde la D36, según el comentario depages/…/[[time]].vue:812. Tres acciones: Dejar feedback / Más tarde / No, gracias.- Contexto automático: al enviar se adjunta en silencio
{ url, site, product, volTime, layers, panel, windLevel, basemap, buildId, viewport, locale, userAgent }, leído dectx/overlayCtx(pages/…/[[time]].vue:214y:504). AñadirruntimeConfig.public.buildId(desdeGIT_SHA) ennuxt.config.tsy pasarlo como ARG en elDockerfile. - Admin:
pages/admin/feedback.vue+pages/admin/login.vue. Tabla nativa + Tailwind, comoCellTable.vue(PrimeVue sigue sin uso; no introducir ahí un patrón nuevo). Filtros por estado y por ocupación, caja de respuesta por hilo,<meta name="robots" content="noindex">. - Strings en español hardcodeado, como el resto del repo (i18n sigue siendo F5; no está
instalado pese a lo que dice
docs/arquitectura.md:43).
Fase 6 — Tests¶
- Unit (
tests/unit/):feedback-nudge.spec.ts(política pura con reloj inyectado),feedback-identity.spec.ts(migración de versión, JSON corrupto,localStorageausente),feedback-dal.spec.ts(misma suite contra live y memory),feedback-machine.spec.ts, y round-trip de los esquemas Zod. - Soporte de escritura en
tests/helpers/pg-sqlite.ts. HoysqliteCompatible(:28) solo traduceBIGSERIAL. Hay que extenderlo:ATTACH DATABASE ':memory:' AS viewerpara los nombres cualificados,TIMESTAMPTZ→TEXT,JSONB→TEXT,now()→CURRENT_TIMESTAMP, quitar::jsonb. Las migraciones dedb/viewer_migrations/se cargan aparte detests/contract/schema/, que sigue siendo espejo byte-exacto del pipeline.Trampa:
asPg()(pg-sqlite.ts:76) traduce$n→?posicional. Si una query de feedback reusa$1, el orden de parámetros se corrompe en silencio y el test pasa con datos mal puestos. Ninguna query nueva debe repetir un placeholder; dejarlo escrito en el helper. - E2E
e2e/feedback.spec.ts, modo fixture (adaptador en memoria, sin Turnstile). Flujo completo: abrir pill → validación de ocupación obligatoria → enviar → aparece en "Mis mensajes"; y el nudge sembrandolamula:feedbackconpage.addInitScript, como hacee2e/prefs.spec.ts:34-39. Respetar la regla de hidratación documentada ene2e/prefs.spec.ts:5-8: un solo click traswaitForLoadState('networkidle'), nunca reintentos sobre acciones con efecto;toPasssolo en asserts idempotentes. Navegar porgetByTestId(convención kebab-case con prefijo de componente). - Goldens: siguen desactivados por defecto (gate
GOLDENS=1,playwright.config.ts:38). El pill nuevo cambia el rail derecho, así que si alguna vez se reactivan hay que regenerarlos — anotarlo, no regenerarlos a ciegas. - CI: sin cambios en
.github/workflows/ci.yml— no hace falta servicio Postgres, el adaptador live se prueba contra better-sqlite3 como el resto.
Fase 7 — Documentación¶
docs/decisiones.md: D39 con el acotamiento de la regla de escritura, el porqué del schemaviewercon rol propio, token-hash en vez de id público, y los descartes (base de datos aparte; tabla en el pipeline; solo-localStorage).docs/maquinas-estado.md: diagrama y notas defeedbackMachine—CLAUDE.mdexige que vaya en el mismo commit que la máquina.docs/feedback.md(nuevo): schema, GRANTs, env vars, runbook de despliegue (aplicar la migración a mano conpsql, igual que el despliegue al Swarm es manual) y cómo usar el admin.CLAUDE.md: actualizar la regla — "jamás escribe en el schemapublicdel pipeline".
Ficheros críticos¶
Nuevos: db/viewer_migrations/0001_feedback.sql, shared/contract/feedback.ts,
server/dal/feedback{,-types,-live,-memory}.ts, server/api/feedback/**, server/api/admin/**,
server/middleware/admin.ts, composables/useFeedbackIdentity.ts, machines/feedback.ts,
utils/feedback/nudge.ts, components/Feedback{Dialog,Nudge}.vue, pages/admin/*.vue,
docs/feedback.md.
Modificados: server/dal/pg.ts (cache por config), server/dal/params.ts (parseBody),
nuxt.config.ts (env vars + buildId), components/LayersMenu.vue (pill + emit),
pages/[site]/[product]/[[time]].vue (montaje y cableado), tests/helpers/pg-sqlite.ts,
Dockerfile (ARG GIT_SHA), shared/contract/index.ts, CLAUDE.md, docs/*.
Verificación¶
pnpm lint && pnpm typecheck && pnpm test— unit en verde, incluida la suite de feedback.bash scripts/check-contract-drift.sh— debe seguir pasando sin cambios: prueba de que el contrato del pipeline no se tocó.pnpm testsobretests/unit/dal.spec.ts— la paridad M1 intacta, sin modificar el fichero.pnpm build && pnpm test:e2e—e2e/feedback.spec.tsrecorre enviar → leer el hilo, y el nudge.- Local contra Postgres real:
docker run postgres:16-alpine,pnpm db:setup, aplicardb/viewer_migrations/0001_feedback.sqly los GRANTs, arrancar conNUXT_PG_*+NUXT_PG_WRITE_*, enviar feedback desde la UI y comprobar conpsqlque la fila está. - Comprobar que el rol de lectura no puede escribir:
INSERTenviewer.feedbackcon el usuario de solo-lectura debe fallar conpermission denied. - Admin: login con
NUXT_ADMIN_TOKEN, responder un hilo, recargar el viewer con el mismo navegador y ver el badge rojo; abrir el hilo y comprobar que el badge desaparece. - Webhook: apuntar
NUXT_FEEDBACK_WEBHOOK_URLa unnc -llocal y verificar que llega el POST y que, con la URL rota, el envío del usuario igual responde 201.