Resumen del sistema
Estado global de la plataforma Lyzard
Documentación del panel
Referencia completa de todas las secciones, funcionalidades y procesos del panel de administración. Mantén esta página actualizada cada vez que se añada algo nuevo.
Índice
- Visión general del panel
- Login, sesión y seguridad
- Resumen
- Tenants
- Funcionalidades por tenant (feature flags)
- Administradores
- Modelo canónico
- Sources
- Mapeos (panel inline en Sources)
- Staging logs
- Motor de staging (cómo se mueven los datos)
- Enlaces del bloque "Sistema"
- Arquitectura técnica resumida
- Metabase: instalación y arquitectura completa
- Librería de queries (SQL multi-tenant)
- API pública del dashboard del tenant
- app.lyzard.es: SPA React + Vite
- Cómo añadir una funcionalidad nueva al panel
Este panel (admin.lyzard.es) es el centro de operaciones del equipo Lyzard. NO es para clientes finales — los clientes acceden al dashboard en lyzard.es.
Desde aquí se gestionan tres cosas:
- Quién usa Lyzard — alta y baja de tenants (clientes) y administradores internos.
- Qué ve cada cliente — feature flags por tenant.
- Cómo se mueven sus datos — desde el ingestion (Airbyte) hasta el data warehouse canonicalizado.
El sidebar agrupa las secciones en cuatro bloques: General, Gestión, Data Warehouse y Sistema. Las dos primeras viven dentro del propio panel; las últimas dos abren herramientas externas (pgweb, Airbyte) o son secciones de operación.
Login
Entras con email + contraseña. Solo aceptan login los usuarios marcados como is_system_admin = true en la BBDD lyzard. Si un cliente con role owner intenta entrar, el panel le rechaza con "Acceso restringido al equipo de Lyzard".
Token JWT
Tras el login el backend devuelve un JWT firmado con JWT_SECRET (caduca en 8h). El token se guarda en sessionStorage del navegador con clave lyzard_admin_token y se envía en cada petición del panel como Authorization: Bearer ….
Cookie de sesión (HttpOnly)
Además del JWT, tras el login el panel llama a POST /api/admin-session y el backend establece una cookie HttpOnly llamada lyzard_admin_session con dominio .lyzard.es. Esta cookie es crítica: nginx la usa con auth_request para proteger /db/ y /dw/ (los visores de BBDD) sin pedir un segundo login.
Logout
Al pulsar el icono de salida se borra la cookie (DELETE /api/admin-session) y se elimina el token de sessionStorage. Si no haces logout, la cookie expira sola a las 8h.
HttpOnly, Secure y SameSite=Strict, pero conviene cerrar sesión en máquinas compartidas.Vista de aterrizaje. Muestra tres contadores globales:
- Tenants — total de filas en la tabla
tenants(incluye inactivos). - Usuarios — total en la tabla
users. - Admins — usuarios con
is_system_admin = true.
Datos en vivo desde GET /api/admin/stats. Se recargan cada vez que abres la página.
Un tenant es un cliente de Lyzard (un workspace aislado). Esta página tiene tres bloques.
4.1 Crear un tenant
Formulario con dos partes: el tenant en sí (nombre + slug) y el primer usuario owner dentro de él (email, contraseña, nombre opcional).
- Nombre — display name del workspace (ej.
Tienda García). - Slug — identificador URL-safe. Se autocompleta al escribir el nombre (sin acentos, solo a-z 0-9 y guiones). Si lo editas a mano, deja de autocompletar.
- Email del owner — único en todo el sistema. No puede repetirse en otro tenant.
- Contraseña — mínimo 8 caracteres. Se hashea con bcrypt cost 12.
- Nombre completo — opcional, solo para mostrar.
Llama a POST /api/tenants, que en una transacción crea la fila en tenants y la fila en users con role owner.
4.2 Lista y activación
Tabla con todos los tenants, su slug, el contador de usuarios y la fecha de creación. La columna Estado es un botón clicable: alterna entre Activo e Inactivo (PATCH /api/admin/tenants/:id). Un tenant inactivo no se elimina — solo se marca is_active = false y los usuarios de ese tenant no pueden hacer login.
4.3 Botón "Funcionalidades"
Abre el panel de feature flags para ese tenant (sección 5). El panel se muestra debajo de la tabla y se cierra con el botón "Cerrar".
Cada tenant ve solo las secciones del dashboard que tenga activadas. La lógica es por capas:
- La tabla
featurescontiene el catálogo global conenabled_by_default. - La tabla
tenant_featurescontiene overrides: solo se inserta una fila cuando el admin cambia el default. - Estado efectivo = override del tenant si existe, si no
enabled_by_default.
Cómo usar el panel
En la fila de un tenant, pulsa Funcionalidades. Aparecen toggles agrupados por categoría (Dashboard, etc.). Cada toggle hace PATCH /api/admin/tenants/:id/features/:key con UPSERT en tenant_features.
Cómo añadir una feature nueva al catálogo
Hoy se hace por SQL: INSERT INTO features (key, name, description, category, sort_order, enabled_by_default) VALUES (...). Convención de key: dashboard.<seccion>. El nombre y descripción aparecen tal cual en el toggle.
Lista los usuarios del equipo Lyzard (is_system_admin = true). Estos son los únicos que pueden entrar en este panel.
6.1 Crear admin
Formulario con email, nombre completo y contraseña. POST /api/admin/admins crea un usuario con role='owner', is_system_admin=true y tenant_id = el tenant del admin que lo crea (el "tenant interno" de Lyzard, no se ve en producto).
6.2 Lista
Email, nombre, workspace, último acceso y estado. Hoy no hay botón para desactivar/borrar admins — se haría manualmente vía SQL.
users.is_active = false o is_system_admin = false directamente en la BBDD.Define qué columnas tiene cada entidad estable del data warehouse (products, orders, customers, …). Es el contrato que verá el dashboard del cliente, independiente de qué source provee los datos.
7.1 Tabla maestra
Las filas viven en config.canonical_columns (en la BBDD lyzard_warehouse). Cada fila tiene entity, column_name, data_type (text/numeric/timestamp/boolean) y required.
7.2 Añadir una columna canónica
Formulario con entidad (autocompletada con las que ya existen, pero puedes escribir una nueva), nombre de columna, tipo y checkbox "Requerida". Al guardar:
- Inserta la fila en
config.canonical_columns. - Llama a
syncEntityTable(entity): sicore.<entity>no existía la crea con todas sus columnas +id,source_raw_id,sourceyUNIQUE(source_raw_id, source). Si ya existía, solo haceALTER TABLE ADD COLUMN. - La columna física aparece al instante en
core.<entity>, vacía.
Para que se rellene con datos hay que mapearla a alguna raw_column en alguna source y esperar al próximo run del motor (sección 11).
7.3 Eliminar una columna canónica
Borra la fila de config.canonical_columns pero NO borra la columna física en core.<entity>. Esto es a propósito: nunca perdemos histórico. La columna queda "huérfana": existe pero el motor deja de actualizarla.
7.4 Tipos válidos y mapeo a Postgres
| data_type | Tipo Postgres |
|---|---|
text | TEXT |
numeric | NUMERIC |
timestamp | TIMESTAMPTZ |
boolean | BOOLEAN |
data_type de una columna ya existente NO ejecuta ALTER COLUMN TYPE. Si necesitas cambiarlo, hazlo a mano en SQL o borra y re-crea la columna.Lista los schemas RAW de Airbyte detectados en lyzard_warehouse. Convención obligatoria de naming:
tenant_<tenant>_<source>_raw
Ejemplos: tenant_test_faker_raw, tenant_cocacola_woo_raw. Cualquier schema que no siga este patrón se ignora.
8.1 Botón "Detectar sources"
Llama a POST /api/admin/sources/detect. El backend busca con regex ^tenant_[^_]+_[^_]+_raw$, parsea cada nombre y hace UPSERT en config.sources. Cualquier source antigua cuyo schema haya desaparecido se marca active=false.
8.2 Tabla
Cada source detectada se muestra con su tenant, source, schema_name, fecha de detección y estado (Activa/Inactiva). El botón Configurar mapeos abre el panel inline (sección 9).
detectSources() antes de cada tick — no necesitas pulsar el botón si das tiempo.El panel se abre debajo de la tabla cuando pulsas "Configurar mapeos" en una source. Define cómo cada columna RAW se vierte a una columna canónica del core.
9.1 Selector de tabla y entidad
Eliges una tabla RAW (las que existan en el schema de la source) y una entidad canónica destino. Si la tabla ya tenía mapeos, la entidad se preselecciona automáticamente.
9.2 Tabla de columnas RAW
Para cada columna de la tabla RAW se muestra:
- Nombre de la columna RAW.
- Tipo origen (lo que dice
information_schema). - 3 ejemplos reales de la BBDD para que sepas qué contiene.
- Selector "→ Columna canónica": las opciones son las columnas canónicas de la entidad seleccionada. Por defecto va en "— No mapear —".
Las columnas _airbyte_* nunca aparecen en este selector.
9.3 Auto-inyección
El motor pone solo cuatro columnas, NO las mapees:
tenant_id← tomado del nombre del schema.source← tomado del nombre del schema.synced_at←_airbyte_extracted_at.source_raw_id←_airbyte_raw_id(clave de deduplicación).
9.4 Botones
- Guardar mapeos —
PUT /api/admin/sources/:id/mappings. Borra todos los mapeos existentes para (source, raw_table) y guarda el set actual. Las columnas en "No mapear" se omiten. - Ejecutar staging para esta source —
POST /api/admin/sources/:id/staging/run. Procesa todas las (raw_table, entity) de esta source ahora mismo, sin esperar al cron.
Histórico y estado del motor. Datos en vivo desde config.staging_runs.
10.1 Última sincronización por source
Tarjetas con la ejecución más reciente por (source, entidad). Útil para ver de un vistazo si algo lleva sin actualizarse.
10.2 Histórico
Tabla con las últimas 200 ejecuciones. Por cada una: inicio, source, entidad, filas procesadas/insertadas/actualizadas, duración y estado (success / error / running). Hover sobre el badge error muestra el mensaje completo.
10.3 Botón "Ejecutar staging ahora"
POST /api/admin/staging/run. Lanza el motor para TODAS las sources activas que tengan mapeos. Útil para no esperar al cron horario.
"Staging" es un proceso, no un schema. Convierte los datos crudos de Airbyte en tablas canónicas. Vive en private/src/services/staging/.
11.1 Pipeline
- Airbyte sincroniza fuentes externas →
tenant_<t>_<s>_raw.<tabla>. - El motor lee
config.column_mappings, agrupa por (source, raw_table, canonical_entity). - Por cada grupo genera UN SQL:
INSERT … SELECT … FROM raw … ON CONFLICT (source_raw_id, source) DO UPDATE SET …. - Filtra siempre:
WHERE _airbyte_meta->>'changes' = '[]'(descarta filas con errores de Airbyte). - Inserta/actualiza en
core.<entity>con casts a los tipos canónicos. - Registra cada ejecución en
config.staging_runscon filas procesadas/insertadas/actualizadas y duración.
11.2 Idempotencia
La clave UNIQUE(source_raw_id, source) permite ejecutar el motor mil veces sin duplicar nada. Las filas existentes se actualizan, las nuevas se insertan.
11.3 Cron
node-cron en el proceso de la API. Frecuencia controlada por la env var STAGING_CRON (default 0 * * * *, cada hora en :00, TZ Europe/Madrid). Si una tick aún corre cuando llega la siguiente, la nueva se salta para no solapar.
11.4 Triggers manuales
- Botón "Ejecutar staging ahora" en Staging logs (todas las sources).
- Botón "Ejecutar staging para esta source" en el panel de mapeos.
11.5 Cuándo se actualiza el core
Solo cuando el motor corre. Cambiar un mapping no actualiza el core hasta el próximo tick. Añadir una columna canónica SÍ refleja en el core inmediatamente (es ALTER TABLE, no datos), pero la columna queda vacía hasta que la mapees y corra el motor.
Los tres ítems del bloque Sistema abren herramientas externas en una pestaña nueva. NO son páginas internas del panel.
12.1 Base de datos (/db/)
Visor read-only de la BBDD operativa lyzard. Implementado con pgweb en 127.0.0.1:5433. nginx lo proxea bajo /db/ con auth_request que valida la cookie lyzard_admin_session. Sin sesión activa → 302 al login.
12.2 Warehouse (/dw/)
Igual que el anterior pero apuntando a lyzard_warehouse (pgweb en :5434, prefijo /dw/). Aquí ves los schemas RAW de Airbyte, config.* y core.*.
12.3 Airbyte (airbyte.lyzard.es)
UI propia de Airbyte (versión 0.63.14 fijada). Aquí se configuran sources, destinations y connections. La autenticación reutiliza la cookie lyzard_admin_session de este panel — no pide segundo login.
12.4 Metabase (metabase.lyzard.es)
UI de Metabase (versión 0.52.13). Es la herramienta de Business Intelligence — aquí construyes dashboards y queries sobre los datos canonicalizados en core.* (o sobre cualquier otra BBDD que conectes desde la propia UI).
- Dónde corre: docker compose en
/opt/metabase/.network_mode: hostcon bind127.0.0.1:3020— nunca expuesto al WAN directamente. - Internal store: Postgres del propio host, BBDD
metabase_internal, usuariometabase_user. NO usa H2 — toda la configuración (dashboards, queries, usuarios de Metabase) persiste en Postgres. - Auth: el subrequest a
/api/admin-sessionprotege el acceso ANTES de llegar a la UI de Metabase. Sin cookie de admin Lyzard → 302 aadmin.lyzard.es. Después Metabase pide su propio login interno la primera vez (paso de setup). Ese login interno se gestiona desde la UI de Metabase, no desde aquí. - Recursos:
JAVA_OPTS=-Xmx512m. Si las queries son lentas, subir gradualmente vigilando memoria del VPS. - Conexión a las BBDD del proyecto: al añadir una database en Metabase usar
Host: 127.0.0.1(porque está en network_mode: host), puerto 5432, y la BBDD que toque (lyzard_warehousees la útil para BI; idealmente con un usuario de solo lectura).
MB_ENCRYPTION_SECRET_KEY en /opt/metabase/.env cifra las contraseñas de las DBs que Metabase guarda. Si se pierde, Metabase no puede leer las credenciales y hay que reconfigurar todas las conexiones a mano. Backup de ese fichero junto con los demás secretos.- Frontend admin: HTML estático en
/var/www/lyzard-admin/index.html. Sin build step, vanilla JS. - API: Node 18 + Express en
/var/www/lyzard/private/, escucha en127.0.0.1:3010, proxiada por nginx. Arrancada víastart.sh(nodemon). - Postgres: una instancia, dos BBDD:
lyzard(operativa) ylyzard_warehouse(DW + staging). - Pools de conexión:
db/pool.js→lyzard;db/warehousePool.js→lyzard_warehouse. - Auth: JWT (8h) + cookie HttpOnly. Middleware
requireAuth+requireSystemAdmin. - Logs: Winston a
private/logs/combined.logyprivate/logs/error.log.
Endpoints actuales del panel
| Método | Ruta | Función |
|---|---|---|
| GET | /api/admin/stats | Contadores del Resumen |
| GET / PATCH | /api/admin/tenants[/:id] | Listar / activar tenants |
| POST | /api/tenants | Crear tenant + owner |
| GET / PATCH | /api/admin/tenants/:id/features[/:key] | Feature flags por tenant |
| GET | /api/admin/features | Catálogo global de features |
| GET / POST | /api/admin/admins | Listar / crear administradores |
| GET / POST / DELETE | /api/admin/canonical-columns | Modelo canónico |
| POST | /api/admin/core/sync | Re-sincroniza todas las core.<entity> |
| GET / POST | /api/admin/sources[/detect] | Listar / detectar sources |
| GET | /api/admin/sources/:id/raw-tables | Tablas dentro de un schema RAW |
| GET | /api/admin/sources/:id/raw-tables/:table/preview | Columnas + 3 filas reales |
| GET / PUT | /api/admin/sources/:id/mappings | Mapeos por source |
| POST | /api/admin/staging/run | Ejecutar motor (todas) |
| POST | /api/admin/sources/:id/staging/run | Ejecutar motor (una source) |
| GET | /api/admin/staging/runs | Histórico de ejecuciones |
| GET / POST / PUT / DELETE | /api/admin/queries[/:id] | CRUD de la librería de queries (ver sección 15) |
| GET | /api/admin/queries/meta | Catálogos de categorías / entidades / chart_types |
| POST | /api/admin/queries/adapt | Adapta una query a multi-tenant (sin guardar) |
| POST | /api/admin/queries/:id/preview | Ejecuta contra tenant=test, máx 5 filas |
| GET | /api/dashboard/queries | Catálogo visible al tenant del JWT (sec. 16) |
| POST | /api/dashboard/queries/:id/run | Ejecuta query con tenant_id forzado del JWT |
| POST / GET / DELETE | /api/admin-session | Cookie HttpOnly para nginx auth_request |
Metabase es la capa de Business Intelligence del stack. Lo desplegamos el 2026-05-07 reutilizando el mismo patrón de protección que Airbyte. Esta sección documenta a fondo cómo está montado, las decisiones de arquitectura, los dos bugs que pisamos durante la instalación, y las operaciones cotidianas. Si en el futuro hay que reinstalar, debugear o actualizar Metabase, este es el documento.
14.1 Resumen de una línea
Contenedor Docker de metabase/metabase:v0.52.13 en network_mode: host, escuchando solo en 127.0.0.1:3020, con su internal store en una BBDD metabase_internal dentro del Postgres del propio VPS, accesible desde fuera vía https://metabase.lyzard.es con el mismo gate de cookie que protege Airbyte y los visores pgweb.
14.2 Arquitectura
browser │ │ https://metabase.lyzard.es ▼ nginx :443 (TLS termination + auth_request) │ ├─ subrequest interno → 127.0.0.1:3010/api/admin-session │ │ (valida la cookie lyzard_admin_session ↔ JWT con isSystemAdmin) │ │ OK 200 → continúa · NOK → 302 a admin.lyzard.es │ │ │ └─ proxy_pass http://127.0.0.1:3020 ▼ Metabase (container, network_mode: host) ← Jetty en 127.0.0.1:3020 │ │ internal store (todo lo que Metabase guarda: dashboards, │ queries, usuarios de Metabase, configuraciones, etc.) ▼ Postgres (proceso del HOST, puerto 5432) └─ DB metabase_internal (owner: metabase_user)
14.3 Postgres (internal store)
Metabase necesita una BBDD persistente para sí mismo. NO usamos H2 (la default) porque H2 vive en un fichero local del contenedor y se pierde si reinstalamos. Usamos Postgres del propio VPS, así toda la configuración de Metabase persiste con los demás datos.
MB_DB_* apuntando a Postgres, así que toda la cadena Liquibase corrió directamente contra metabase_internal. No existe ni existió un metabase.db.mv.db. Si en el futuro alguien sugiere "migrar H2 a Postgres" en esta instancia, NO hace falta hacer nada — ya está. El procedimiento load-from-h2 solo aplica cuando se ha arrancado Metabase ANTES sin esas env vars.- BBDD:
metabase_internal - Usuario:
metabase_user(owner de la BBDD) - Password: guardada en
/opt/metabase/.envcomoMB_DB_PASS - pg_hba: añadida una línea idéntica al patrón del warehouse:
host metabase_internal metabase_user samehost scram-sha-256
La keywordsamehostmatchea cualquier IP que pertenezca a una interfaz local del servidor (loopback, docker0, eth0/WAN). Es restrictiva por DB+usuario para que ningún otro contenedor pueda intentar autenticarse contra esa BBDD.
14.4 docker-compose.yaml — la decisión clave: network_mode: host
El compose vive en /opt/metabase/docker-compose.yaml, separado del de Airbyte. La configuración es minimalista:
services:
metabase:
image: metabase/metabase:v0.52.13
container_name: metabase
restart: always
network_mode: host
env_file: .env
healthcheck: ...
La línea network_mode: host es la decisión NO obvia. Por qué la elegimos:
Si dejas que docker compose cree su propia red bridge (lo normal), tu contenedor recibe una IP en una subred custom (en este caso 172.21.0.0/16). Cuando ese contenedor intenta conectar al Postgres del host vía 172.17.0.1, los paquetes cruzan dos bridges Docker. Docker NO hace SNAT en ese cruce — el paquete sale del kernel con la IP raw del contenedor (172.21.0.2). Postgres recibe la conexión y ejecuta la regla samehost: 172.21.0.2 NO es una IP de ninguna interfaz del host (es del contenedor), así que la regla no matchea y Postgres rechaza con no pg_hba.conf entry for host "172.21.0.2".
Con network_mode: host el contenedor comparte el namespace de red del propio VPS. La conexión a Postgres ya no tiene que cruzar bridges: MB_DB_HOST=127.0.0.1 sale por loopback, Postgres ve 127.0.0.1 como source, samehost matchea, scram-sha-256 valida la password y entra. Cero magia de NAT.
Riesgo de host network: el contenedor podría intentar bindear a cualquier puerto del host. Lo mitigamos con dos env vars que fuerzan a Jetty a escuchar SOLO en loopback:
MB_JETTY_HOST=127.0.0.1 MB_JETTY_PORT=3020
Así Metabase queda colgado en 127.0.0.1:3020, exactamente como si tuviéramos un ports: "127.0.0.1:3020:3000" tradicional pero sin la complicación de bridges.
samehost. Para Metabase no podíamos confiar en esa rareza, así que elegimos host network.14.5 .env — secretos y JAVA_OPTS
El fichero /opt/metabase/.env contiene:
MB_DB_*— coordenadas de la BBDD interna (host, port, dbname, user, pass).MB_ENCRYPTION_SECRET_KEY— clave AES con la que Metabase cifra las contraseñas que guarde de las DBs que conectes desde su UI. Si la pierdes, todas esas credenciales quedan ilegibles y hay que reconfigurar conexiones manualmente. Backup obligatorio.MB_SITE_URL=https://metabase.lyzard.es— para que los enlaces que Metabase genere (por ejemplo en emails) apunten al hostname público.MB_JETTY_HOST,MB_JETTY_PORT— el bind a 127.0.0.1:3020.JAVA_OPTS—-Xmx512m -Xms256m -Dorg.quartz.jobStore.isClustered=false. Esto último merece su propia subsección.
14.6 La trampa del Quartz scheduler (segundo bug pisado)
Una vez resuelto el problema del bridge, Metabase entró en bucle de reinicio con un error críptico durante las migraciones de Liquibase:
Migration failed for changeset migrations/001_update_migrations.yaml::v46.00-086::calherries Reason: Cannot run without an instance id.
Diagnóstico paso a paso:
- Liquibase aplica las migraciones del internal store en orden cronológico.
- Algunas changesets son "custom migrations" en código Clojure, no SQL. Por ejemplo
DeleteAbandonmentEmailTask, que limpia un job antiguo del scheduler de Quartz. - Para limpiar ese job, la migración instancia un Quartz scheduler temporal vía
clojurewerkz.quartzite.scheduler/initialize. - El scheduler lee su configuración del
quartz.propertiesempaquetado enmetabase.jar. Ese fichero declara:org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.dataSource = db org.quartz.jobStore.isClustered = true
- Con
isClustered=true, Quartz ejecuta una validación al arrancar: si elinstanceIdresulta nulo o vacío, lanzaIllegalStateException("Cannot run without an instance id"). - En el flujo normal de Metabase (runtime) la dataSource y el instanceId los inyecta el código de la app antes de instanciar el scheduler. Pero durante una migración Liquibase ese código aún no se ha ejecutado, así que el scheduler arranca con dataSource sin configurar e instanceId vacío. Crash.
Fix: añadir -Dorg.quartz.jobStore.isClustered=false en JAVA_OPTS. Las propiedades del sistema con prefijo org.quartz. sobreescriben las del fichero, y al desactivar el modo cluster la validación deja de ejecutarse. Como Lyzard corre una sola instancia de Metabase, no perdemos nada al desactivar el clustering.
databasechangelog. El bug está en el quartz.properties del JAR (presente en todas las versiones desde la introducción de la migración custom). La causa raíz es isClustered=true shippeado por defecto. Solo el fix por JAVA_OPTS resolvió el problema definitivamente.14.7 Nginx — el gate de autenticación
Vhost en /etc/nginx/conf.d/metabase.lyzard.es.conf. Réplica del pattern de Airbyte. Lo importante:
- Puerto 80 redirige todo a HTTPS, excepto
/.well-known/acme-challenge/(para renovaciones de certbot). - Puerto 443 termina TLS y aplica el gate.
- Subrequest interno
location = /__metabase_auth:auth_requestenvía un GET al backend de Lyzard (127.0.0.1:3010/api/admin-session) que valida la cookielyzard_admin_sessiony devuelve 200 (allow) o 401 (deny). Se desactiva CORS borrando los headersOriginyRefererantes del subrequest, porque el middleware CORS del backend rechazaría la petición interna como cross-origin. - Bloque
location /aplicaauth_request /__metabase_authy proxypassa ahttp://127.0.0.1:3020. - Caso 401/403: redirige a
https://admin.lyzard.es/?metabase=unauthorizedpara que el operador haga login y vuelva. - Timeouts:
proxy_read_timeout 600sporque Metabase a veces tarda mucho en queries largas (long-poll en la UI).
14.8 SSL via Let's Encrypt
Mismo patrón que el resto del proyecto:
- Pre-creamos
/var/www/lyzard-metabase-webroot/. - Subimos un nginx vhost mínimo solo con HTTP y la location
/.well-known/acme-challenge/apuntando al webroot. - Lanzamos
certbot certonly --webroot. Emite el cert. - Reemplazamos el vhost por el completo (HTTPS + auth_request).
- Renovación automática via el timer de certbot (sin intervención).
Cert válido hasta 2026-08-05. Renueva ~30 días antes.
14.9 Doble nivel de autenticación
Metabase tiene su propio sistema de login. Esto significa que para entrar tienes que pasar DOS puertas:
- Puerta 1 — nginx: la cookie
lyzard_admin_session(administrador del sistema Lyzard). Sin ella, redirección a admin.lyzard.es. - Puerta 2 — Metabase: usuario y contraseña del propio Metabase. Se crea la primera vez vía wizard. Es independiente de los usuarios de Lyzard — pueden coincidir en email pero las contraseñas son separadas.
La primera puerta protege contra accesos externos no autorizados. La segunda gestiona permisos finos dentro de Metabase (quién ve qué dashboard, quién puede crear queries, etc.).
14.10 El wizard inicial (a hacer una sola vez)
- Visita
https://metabase.lyzard.esautenticado como admin Lyzard. - Idioma → Español.
- Crea el usuario admin de Metabase: email + nombre + contraseña. Recomendación: misma cuenta de email pero contraseña distinta para que no haya confusión entre la sesión de Lyzard y la de Metabase.
- "Add your data" → puedes saltarlo y conectarlo después desde Settings → Databases. Las credenciales para conectar al warehouse están en la sub-sección 14.10.1.
- Usage tracking → desactivar si no quieres telemetría hacia Metabase Inc.
14.10.1 Usuario read-only para conectar el warehouse
Existe un usuario Postgres dedicado solo a leer desde Metabase: lyzard_warehouse_reader. Tiene SELECT en core.* y config.*, NO ve los schemas tenant_*_*_raw y cualquier intento de INSERT/UPDATE/DELETE es rechazado por Postgres. Las default privileges están configuradas para que tablas/secuencias futuras creadas por lyzard_warehouse_user en esos dos schemas hereden el SELECT automáticamente.
Valores para el formulario "Add Database" en Metabase:
| Campo | Valor |
|---|---|
| Database type | PostgreSQL |
| Display name | Lyzard Warehouse |
| Host | 127.0.0.1 |
| Port | 5432 |
| Database name | lyzard_warehouse |
| Username | lyzard_warehouse_reader |
| Password | guardada en /opt/metabase/warehouse-reader.env (línea WAREHOUSE_READER_PASS=...) |
| SSL | desactivado (loopback, no sale del host) |
| SSH tunnel | no |
Después de añadir la BBDD, Metabase escanea las tablas y guarda metadata. Verás core.products, core.orders, core.customers, las tablas de config, etc.
14.10.2 Dónde están guardadas las credenciales del reader
- Fichero:
/opt/metabase/warehouse-reader.env(chmod 600, root:root). - Contiene:
WAREHOUSE_READER_HOST,WAREHOUSE_READER_PORT,WAREHOUSE_READER_DBNAME,WAREHOUSE_READER_USER,WAREHOUSE_READER_PASS. - NO se carga en docker-compose (el compose solo lee
.env). Es una nota persistente para que cuando configures la conexión en la UI de Metabase no tengas que adivinar la password. - Para verla rápido:
cat /opt/metabase/warehouse-reader.env(necesitas root). - Si la pierdes, regenera password en Postgres y reescribe el fichero:
sudo -u postgres psql -c \\ "ALTER ROLE lyzard_warehouse_reader PASSWORD '$(openssl rand -hex 32)';" # luego reflejar el nuevo valor en /opt/metabase/warehouse-reader.env # y actualizar la conexión existente en Metabase Settings → Databases
14.11 Operaciones del día a día
| Acción | Comando |
|---|---|
| Ver estado | docker inspect metabase --format '{{.State.Health.Status}}' |
| Logs en vivo | docker logs -f metabase |
| Reiniciar | cd /opt/metabase && docker compose restart |
| Apagar | cd /opt/metabase && docker compose down |
| Subir versión | editar docker-compose.yaml → cambiar tag → docker compose pull && docker compose up -d. Las migraciones del internal store corren solas. Mantener isClustered=false en JAVA_OPTS por si hay nuevas custom migrations afectadas. |
| Backup del internal store | pg_dump -U metabase_user -h 127.0.0.1 metabase_internal > metabase_$(date +%F).sql |
| Restore | psql -U metabase_user -d metabase_internal < metabase_FECHA.sql |
14.12 Recursos y límites
- Memoria reservada:
-Xmx512m -Xms256m. En idle Metabase consume ~600-800 MB resident. - El VPS tiene 3.8 GB y Airbyte ya usa ~2 GB. El swap (16 GB) absorbe picos.
- Si el dashboard empieza a renderizar lento o las queries fallan por OOM, subir
-Xmxen pasos de 256 MB. No subir más allá de 1 GB sin ampliar la RAM del VPS.
14.13 Pipeline de datos completo
┌──────────┐ ┌─────────────────────┐ ┌──────────────┐ ┌──────────┐ │ Fuentes │ ─▶ │ Airbyte (sync) │ ─▶ │ Motor stagi. │ ─▶ │ Metabase │ │ externas │ │ → tenant_X_Y_raw.* │ │ → core.* │ │ → BI/UI │ └──────────┘ └─────────────────────┘ └──────────────┘ └──────────┘ APIs cron Airbyte cron Lyzard tu trabajo DBs (cada N horas) cada hora de análisis
Metabase es la última pieza: lee core.* (o cualquier otra BBDD) y la convierte en preguntas y respuestas visuales. NO escribe nunca en esos schemas, solo SELECT.
14.14 Ficheros que existen gracias a este deploy
/opt/metabase/docker-compose.yaml/opt/metabase/.env(secretos)/etc/nginx/conf.d/metabase.lyzard.es.conf/var/www/lyzard-metabase-webroot//etc/letsencrypt/live/metabase.lyzard.es/*/etc/postgresql/15/main/pg_hba.conf(línea añadida para metabase_user)- BBDD
metabase_internalen Postgres + rolemetabase_user
Sistema para guardar queries SQL parametrizables que se ejecutan filtrando por tenant_id. Las queries se construyen visualmente en Metabase, se pegan aquí, el sistema las adapta para multi-tenancy y luego cada dashboard de cliente las renderiza filtradas por el tenant correspondiente.
15.1 Tabla en BBDD
config.queries en lyzard_warehouse. Columnas:
id,name,descriptionsql_template— el SQL con{{tenant_id}}como placeholder.category— uno deessential,ecommerce,saas,restaurant(CHECK).entity— opcional:orders,products,customers,other.chart_type— opcional:line,bar,kpi,table.active— soft delete; el botón "Desactivar" solo cambia este flag, NO borra la fila.created_at,updated_at(este último mantenido por trigger).
15.2 Lógica de adaptación multi-tenant (library.adaptForTenant)
Recibe un SQL de Metabase y devuelve la versión con tenant_id inyectado.
- Trim + quita
;finales. - Valida que sea un SELECT/WITH (sección 15.4).
- Si la query ya menciona
tenant_iden código (fuera de strings y comentarios) → la deja sin tocar y reporta no modificada. - Si NO contiene
tenant_id, escanea la query a profundidad de paréntesis 0 (ignora subqueries) y localiza:- la posición de la
WHEREouter (si existe); - el primer terminador outer entre
GROUP BY,ORDER BY,LIMIT,HAVING,OFFSET,FETCH,UNION,INTERSECT,EXCEPT,WINDOW,FOR.
- la posición de la
- Si hay
WHEREouter: añadeAND tenant_id = {{tenant_id}}justo antes del terminador (o al final). - Si NO hay
WHEREouter: insertaWHERE tenant_id = {{tenant_id}}antes del terminador (o al final).
El parser respeta strings ('...', "") y comentarios (--, /* */), así que WHERE dentro de un string nunca se interpreta como cláusula real.
Limitaciones conocidas:
- UNION: la inyección se aplica al final, no a cada SELECT del UNION. Si tu query usa UNION, escribe el
tenant_idmanualmente en cada rama y deja que el sistema detecte que ya está presente. - JOINs con alias: si la outer query tiene
FROM t1 JOIN t2, el sistema añadetenant_id = ...sin alias. Postgres rechazará sitenant_ides ambiguo. En ese caso edita el resultado para usart1.tenant_idantes de guardar. - CTEs: las CTEs proyectan tenant_id transparentemente solo si los SELECT internos lo seleccionan. Si filtras dentro de la CTE y proyectas tenant_id al exterior, perfecto; si no, el tenant_id se filtra solo en el outer SELECT (que para queries simples basta).
{{tenant_id}} resaltado. Si la heurística no acertó (caso UNION, JOIN ambiguo, CTE rara), edita a mano antes de guardar.15.3 Ejecución parametrizada
Al ejecutar (preview o desde el dashboard del tenant) NO se concatenan strings. El flujo es:
- Sustituye los placeholders presentes por índices
$Na nivel textual:{{tenant_id}}→$1(obligatorio en toda query){{date_from}}→$2(opcional, solo si la query lo usa){{date_to}}→$3(opcional, solo si la query lo usa)
- Cada placeholder admite estar entre comillas o no:
'{{tenant_id}}'y{{tenant_id}}producen el mismo$Nsin las comillas externas (porque el driverpgya lo trata como literal). - Envuelve la query con
SELECT * FROM (...) AS _preview LIMIT 5para acotar el resultado. - Abre transacción →
SET LOCAL statement_timeout = '5000ms'. - Ejecuta
client.query(wrapped, [tenantId, dateFrom, dateTo])— el array solo contiene los parámetros realmente referenciados por la query, asípgno se queja por sobrantes. ROLLBACKSIEMPRE (también en éxito) para garantizar que cualquier efecto colateral no persiste.
El preview hardcodea valores por defecto (tenantId='test', rango de fechas 2000-01-01..2099-12-31) para que cualquier query del catálogo se pueda visualizar sin que el operador tenga que pensar parámetros. El dashboard final del cliente pasará los suyos.
15.4 Validaciones de seguridad
- La query DEBE empezar por
SELECToWITH(tras quitar comentarios y espacios iniciales). - Palabras reservadas prohibidas:
INSERT,UPDATE,DELETE,DROP,TRUNCATE,ALTER,GRANT,REVOKE,CREATE,COMMENT,COPY,CALL,MERGE,EXECUTE,DO,PREPARE,DEALLOCATE,VACUUM,REINDEX,CLUSTER,LOCK,NOTIFY,LISTEN,UNLISTEN. Detección con tokenización que ignora strings/comentarios para que palabras dentro de un literal no disparen falsos positivos. - Múltiples sentencias bloqueadas: cualquier
;seguido de algo que no sea whitespace o comentario falla la validación. - El placeholder único permitido es
{{tenant_id}}. La query DEBE incluirlo antes de guardar — el sistema lo verifica. - El preview corre exclusivamente con
tenant=test, hardcodeado en el endpoint. El frontend no puede enviarle otro tenant.
15.5 Endpoints (montados bajo /api/admin/queries)
| Método | Ruta | Función |
|---|---|---|
| GET | / | Lista queries (params: ?active=true, ?category=...) |
| GET | /meta | Devuelve catálogos: categorías, entidades, tipos de gráfico, placeholder, tenant de preview |
| POST | /adapt | Recibe { sql }, devuelve { sql, modified, note } sin guardar nada. Sirve para el botón "Adaptar" |
| GET | /:id | Detalle |
| POST | / | Crea |
| PUT | /:id | Edita |
| DELETE | /:id | Soft delete (active=false) |
| POST | /:id/preview | Ejecuta contra tenant=test, devuelve hasta 5 filas |
15.6 Panel — vista lista
Tabla con todas las queries, filtro por categoría, botones "Editar" y "Desactivar" por fila, botón "Nueva query" arriba. Las queries inactivas siguen apareciendo (con badge gris) — para activarlas hay que entrar a editar y marcar el checkbox.
15.7 Panel — vista form (new / edit)
- Campos: nombre, descripción, categoría, entidad, tipo de gráfico, SQL, checkbox activa.
- Botón "Adaptar para multi-tenant": llama a
POST /adapt, muestra el resultado en un bloque readonly con{{tenant_id}}resaltado en lima. También sobreescribe el textarea con la versión adaptada para que sea exactamente lo que se guarda. - Botón "Preview": solo se habilita después de guardar (porque el endpoint
/previewtrabaja sobre elid). Muestra hasta 5 filas contenant=testhardcodeado. - Botón "Guardar": si pulsas guardar antes de adaptar, falla con un mensaje pidiéndolo.
- Editar y guardar repetidamente actualiza la misma fila en BBDD; el trigger
queries_set_updated_atmantieneupdated_atal día.
15.8 Soft delete vs purga
El botón "Desactivar" del panel lanza DELETE /api/admin/queries/:id que internamente hace UPDATE config.queries SET active = false. NO borra la fila. Razón: dashboards de tenants pueden estar referenciando el id de la query, y un DELETE real rompería esos dashboards. Si quieres purgar de verdad, hazlo manualmente vía SQL después de comprobar que ningún dashboard la usa.
15.9 Cómo lo usa el dashboard del tenant (futuro)
Pendiente de implementar pero el contrato es: el dashboard del cliente, al renderizar, llama a un endpoint del backend pasándole el id de la query, su tenantId (extraído del JWT) y el rango de fechas seleccionado en la UI (selector temporal). El backend usa library.executeWithTenant(template, { tenantId, dateFrom, dateTo }). La capa de feature flags decide qué queries se ofrecen a cada tenant según su categoría/plan.
15.10 Catálogo inicial (seed 008_query_seed.sql)
El 2026-05-07 se sembró un catálogo de 36 queries de partida que cubre tres áreas:
| Categoría | Queries | Foco |
|---|---|---|
essential | 8 | KPIs y series temporales que sirven a cualquier tenant: revenue, ticket medio, clientes únicos, nuevos vs recurrentes. |
restaurant | 22 | Rentabilidad (food cost, margen), operaciones (no-shows, ocupación), productos (top platos, mix), personal (camareros, propinas), inventario (merma, productos críticos). |
ecommerce | 6 | Tasa de devolución, ranking productos, tiempo medio hasta compra, abandono de carrito, LTV. |
Importante: muchas queries de restaurant referencian columnas que NO existen aún en el modelo canónico (cost, guests, shift, waiter_id, tip, waste_value, purchase_value, category, stock, min_stock, duration_minutes, is_reservation, is_no_show, margin_percent). Cada una de esas queries lleva un comentario -- TODO identificando qué columna falta. Cuando se conecte una fuente real (POS de restaurante, sistema de inventario, etc.) hay que:
- Añadir la columna canónica desde
/admin → Modelo canónico(sección 7). - Mapear la columna RAW correspondiente en
/admin → Sources(sección 9). - Esperar al próximo run del motor de staging (o lanzarlo manualmente).
- La query empezará a devolver datos sin más cambios.
Las queries de essential y ecommerce sí funcionan con el modelo actual (las columnas que usan ya existen en core.orders, core.products, core.customers).
Endpoints que consumirá el frontend del cliente final (lyzard.es/dashboard). Viven en /api/dashboard/* y son distintos de los /api/admin/*: cualquier usuario autenticado del tenant puede llamarlos, no requieren isSystemAdmin.
16.1 Frontera de seguridad multi-tenant
La regla de oro: el tenant_id SIEMPRE sale del JWT validado del usuario, nunca del body o query string. Esto significa que el cliente A no puede ejecutar una query con tenant_id='B' ni siquiera modificando la petición — el backend ignora cualquier intento.
El JWT del usuario incluye tenantId (UUID en lyzard.tenants.id) y tenantSlug (texto, ej. 'test', 'cocacola'). Para ejecutar queries usamos tenantSlug porque coincide con el valor que el motor de staging escribe en core.*.tenant_id (extraído del nombre del schema RAW de Airbyte).
16.2 Gating por feature flags
Tres features controlan qué CATEGORÍAS de queries ve cada tenant:
| Feature key | Default | Categoría que activa |
|---|---|---|
dashboard.queries.essential | ON | queries con category='essential' |
dashboard.queries.restaurant | OFF | queries con category='restaurant' |
dashboard.queries.ecommerce | OFF | queries con category='ecommerce' |
Para activar restaurant/ecommerce a un tenant concreto: /admin → Tenants → Funcionalidades. El admin marca el toggle, se inserta una fila en tenant_features, y el endpoint del dashboard empieza a devolver esas queries en la siguiente llamada.
/run la rechaza con 403. No basta con ocultar; bloquea explícitamente.16.3 GET /api/dashboard/queries
Devuelve el catálogo de queries visibles para el tenant del JWT. NUNCA incluye el sql_template — el cliente solo necesita el id, nombre, descripción, categoría, entidad y chart_type para renderizar.
[
{ "id": 1, "name": "Revenue total", "category": "essential",
"entity": "orders", "chart_type": "kpi",
"description": "Suma de ventas en el periodo seleccionado." },
{ "id": 4, "name": "Revenue por día", "category": "essential",
"entity": "orders", "chart_type": "line", ... },
...
]
16.4 POST /api/dashboard/queries/:id/run
Ejecuta la query y devuelve los datos para renderizar el chart.
Body (todo opcional, depende de qué placeholders use la query):
{ "date_from": "2026-01-01", "date_to": "2026-12-31" }
Respuesta 200:
{
"id": 1,
"name": "Revenue total",
"chart_type": "kpi",
"entity": "orders",
"rows": [{ "revenue_total": "12345.67" }],
"fields": [{ "name": "revenue_total", "dataTypeID": 1700 }],
"elapsedMs": 4
}
El frontend usa chart_type para decidir qué componente renderizar (kpi card, line chart, bar chart, pie chart, table). El shape de rows respeta la convención del catálogo (kpi: 1 fila con alias claro; line: (fecha, valor); bar/pie: (label, valor); table: columnas variadas).
16.5 Códigos de error
| Código | Cuándo | Cómo lo trata el frontend |
|---|---|---|
| 401 | JWT ausente / inválido / expirado | Redirigir a login |
| 403 | Tenant intenta query de categoría no habilitada | Mensaje "tu plan no incluye esta funcionalidad" |
| 404 | Query no existe o está inactiva | Ocultar widget |
| 408 | statement_timeout (query > 5s) | Mensaje de retry, considerar optimizar |
| 422 | Columna referenciada no existe en core.* (TODO no resuelto) | "Datos no disponibles aún" — placeholder visual |
| 400 | Formato de fecha inválido / sentencia rechazada por validador | Bug del frontend; corregir y retry |
16.6 Diferencias con /api/admin/queries
| Aspecto | /api/admin/queries | /api/dashboard/queries |
|---|---|---|
| Auth requerida | isSystemAdmin | cualquier usuario autenticado |
| tenantId | hardcodeado a 'test' (preview) | extraído del JWT del cliente |
| Filtro por feature flags | no | sí, por categoría |
Devuelve sql_template | sí (admin lo edita) | NO (cliente nunca lo ve) |
| Límite de filas | 5 (preview) | el que ponga la query (LIMIT en el SQL) |
| CRUD | POST/PUT/DELETE | solo GET y POST /run |
16.7 Cómo lo usará el frontend (SPA React + Vite)
- Login del usuario contra
POST /api/auth/login→ recibe JWT, lo guarda en sessionStorage. - Al abrir el dashboard:
GET /api/dashboard/queriescon el Bearer → recibe el catálogo. Para cada query renderiza un<Widget queryId=... chartType=... />en el grid. - Cuando el usuario cambia el rango de fechas global:
POST /api/dashboard/queries/:id/runcon{ date_from, date_to }por cada widget visible. TanStack Query cachea por(queryId, dateRange). - El componente de widget elige el chart de Recharts según
chart_typey le pasarowsdirecto.
16.8 Quién hace test rápido en CLI
node -e "
require('dotenv').config({path:'/var/www/lyzard/private/.env'});
const jwt = require('jsonwebtoken');
console.log(jwt.sign({
sub:'cli', email:'cli@test', role:'owner',
tenantId:'', tenantSlug:'test', isSystemAdmin:false
}, process.env.JWT_SECRET, { expiresIn:'1h' }));
"
# Y luego:
TOK=...
curl -sS http://127.0.0.1:3010/api/dashboard/queries -H "Authorization: Bearer $TOK"
curl -sS -X POST http://127.0.0.1:3010/api/dashboard/queries/1/run \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"date_from":"2024-01-01","date_to":"2024-12-31"}'
El 2026-05-07 migramos el dashboard del cliente a una SPA React + Vite en su propio subdominio. Esta sección documenta la arquitectura, el desarrollo y los gotchas — todo desarrollo de cliente desde ahora vive en este stack.
17.1 Routing de subdominios después de la migración
| URL | Sirve |
|---|---|
app.lyzard.es | SPA React. Login + dashboard. Único punto de entrada para clientes finales. |
lyzard.es | 301 → app.lyzard.es (preserva ruta y query). Para no romper bookmarks/links antiguos. /api/* sigue funcionando aquí también, no se redirige. |
admin.lyzard.es | Panel interno (vanilla HTML, intacto). |
airbyte.lyzard.es | UI Airbyte. |
metabase.lyzard.es | UI Metabase. |
17.2 Stack y dependencias
- Vite 5 + React 18 + react-router-dom 6. JS plano (no TypeScript de momento).
- Bundle de salida: ~199 KB JS / 19 KB CSS gzipped (single chunk, no code splitting todavía).
- Sin Tailwind, sin Redux, sin librería de UI. Reusamos el CSS vanilla (
main.css+dashboard.css) literalmente para preservar diseño.
17.3 Estructura del proyecto
/var/www/lyzard/
├── private/ ← backend Express (sin cambios)
├── public/ ← legacy vanilla, ahora obsoleto. Conservado por seguridad.
├── app-source/ ← CÓDIGO FUENTE del SPA — editar aquí
│ ├── package.json
│ ├── vite.config.js ← outDir → ../app-public, dev proxy a 127.0.0.1:3010
│ ├── index.html
│ ├── public/ ← assets estáticos (logo, etc)
│ └── src/
│ ├── main.jsx ← entry point
│ ├── App.jsx ← router + providers
│ ├── api.js ← cliente HTTP fino
│ ├── main.css ← variables + auth styles (copia de vanilla)
│ ├── dashboard.css← layout dashboard (copia de vanilla)
│ ├── context/
│ │ ├── AuthContext.jsx ← token, refresh, login/logout
│ │ └── FeaturesContext.jsx ← carga GET /api/features
│ ├── components/
│ │ ├── RequireAuth.jsx ← guard de rutas privadas
│ │ └── Toast.jsx ← notif global
│ ├── layout/
│ │ ├── Layout.jsx ← shell con sidebar + topbar
│ │ └── Sidebar.jsx
│ └── pages/
│ ├── Login.jsx
│ ├── Settings.jsx ← perfil + password + notifs + cuenta
│ ├── Users.jsx ← list + create + permisos
│ └── Placeholder.jsx ← Resumen / Actividad / Facturación
└── app-public/ ← BUILD del SPA — NO editar a mano. nginx sirve desde aquí.
├── index.html
└── assets/index-XXX.{js,css}
17.4 Cómo desarrollar localmente
cd /var/www/lyzard/app-source npm install # solo la primera vez npm run dev # arranca Vite en :5173 con HMR # Vite proxea /api/* a 127.0.0.1:3010 (el Express ya corriendo). # Abrir http://<ip-vps>:5173 (o tunelar via SSH).
Para ver el cambio en producción:
cd /var/www/lyzard/app-source && npm run build
npm run build deja los ficheros listos en /var/www/lyzard/app-public/. nginx los sirve inmediatamente, sin reload necesario (ficheros estáticos). El bundle JS y CSS llevan hash en el nombre (index-D_FXOPFA.js) para invalidar cache automáticamente.
17.5 Auth: token + cookie de refresh
- Access token JWT en
sessionStorage.lyzard_token. Caduca en 8h. - Refresh token en cookie HttpOnly
lyzard_refresh, dominio.lyzard.es(para que sobreviva a la migración entre subdominios), path/api/auth/refresh, SameSite=Strict, secure. Caduca en 30 días. AuthContexthace bootstrap al cargar la app: si hay token usable lo programa para refresh; si no, intentaPOST /api/auth/refreshcon la cookie. Si todo falla, redirige a/login.- El refresh se schedule automáticamente para 60s antes de la expiración del access token. Si falla, fuerza logout.
domain: '.lyzard.es' al res.cookie('lyzard_refresh', ...) en /private/src/routes/auth.js para que la cookie viaje entre lyzard.es y app.lyzard.es. Sin este cambio, los usuarios tendrían que loguearse de nuevo tras la migración.17.6 Feature flags integrados
FeaturesContext hace GET /api/features al autenticarse y expone un Set de keys habilitadas. La sidebar lee ese set y oculta items cuyo featureKey no está habilitado. Ejemplo: si el tenant no tiene dashboard.users activado en el panel admin, el item "Usuarios" no aparece y la ruta /dashboard/users sigue siendo accesible (el backend la denegará, pero el navegador no la mostrará vacía).
17.7 Rutas del SPA
| Path | Componente | Notas |
|---|---|---|
/ | Navigate → /dashboard | Bookmark histórico |
/login | Login | Si ya autenticado → /dashboard |
/dashboard | Settings (default) | Mismo que vanilla — ajustes es la home |
/dashboard/overview | Placeholder | Pendiente |
/dashboard/activity | Placeholder | Pendiente |
/dashboard/users | Users | List + create + panel permisos por usuario |
/dashboard/billing | Placeholder | Pendiente |
| cualquier otro | Navigate → /dashboard | Catch-all del router |
Todas las rutas /dashboard/* están protegidas por <RequireAuth /> que comprueba que el bootstrap haya terminado y haya token. Si no, navega a /login guardando la ruta original en state.from para volver tras login.
17.8 La trampa de los body styles
main.css y dashboard.css declaran cada uno reglas para body {} que son incompatibles entre sí (login=centrado, dashboard=flex con sidebar). En vanilla solo se cargaba la CSS necesaria por página. En la SPA cargamos las DOS de inicio. Solución: refactoramos las reglas de body a body.page-auth y body.page-dashboard, y un componente <BodyClassSync /> aplica la clase correcta al body en cada cambio de ruta vía useLocation + useEffect.
17.9 Convenciones para el desarrollo futuro
- Cualquier código de cliente nuevo va aquí, no en
/var/www/lyzard/public/(legacy). Aquellos ficheros son obsoletos. - Llamadas al backend: usar siempre helpers de
src/api.js(apiFetch,getMe, etc.). NO inlinearfetch(). Centraliza Auth header, parseo, errores. - Estado server: hoy usamos
useState+useEffect. Cuando la complejidad crezca (ej. cuando lleguen los charts del dashboard analítico), introducimos TanStack Query. Hasta entonces no merece la pena. - Estilos: extender los CSS existentes en lugar de modificar las clases ya utilizadas. Si una página nueva necesita estilos propios, crear un fichero CSS y importarlo desde el componente.
- Toasts:
useToast().show(msg, 'success' | 'error'). Nada dealert(). - Permisos por rol: el
user.roleestá disponible víauseAuth().user.role. Para gating de UI usar comparación directa contra'owner','admin','member'.
17.10 Lo que NO se ha portado todavía
Sigue funcionando en backend pero no se renderiza en SPA aún:
- Página de Resumen (placeholder)
- Página de Actividad (placeholder)
- Página de Facturación (placeholder)
- Dashboard analítico con gráficos (sec. 16) — pendiente, será sub-ruta de
/dashboardo sub-app aparte
17.11 Operaciones del día a día
| Acción | Comando |
|---|---|
| Build de producción | cd /var/www/lyzard/app-source && npm run build |
| Dev server con HMR | cd /var/www/lyzard/app-source && npm run dev |
| Verificar build servido | curl -sI https://app.lyzard.es/ |
| Ver bundle | ls -lh /var/www/lyzard/app-public/assets/ |
| Forzar rebuild limpio | rm -rf /var/www/lyzard/app-public/* && npm run build |
Cada vez que añadas algo, sigue este checklist:
- Implementa la lógica (backend + frontend).
- Si requiere un toggle por tenant, registra la feature en la tabla
features(ver sección 5). - Documenta en esta misma página: añade una sección nueva al final con su ID anclado, su entrada en el índice y un explainer en el mismo tono que las demás (qué hace, cómo se usa, gotchas).
- Si tocaste schemas
config.*ocore.*, añade migración enprivate/src/db/migrations/y ejecútala contralyzard_warehouse. - Si añadiste un nuevo endpoint, agrégalo a la tabla de la sección 13.
- Si añadiste una query SQL nueva, hazlo desde la propia página "Librería de queries" (sección 15) — NO toques la tabla a mano salvo que sea una migración estructural.
- Si el deploy implicó decisiones técnicas no obvias (network mode, workarounds, secretos), abre una sección dedicada con el patrón de la sección 14 de Metabase: arquitectura, decisión clave + por qué, trampas pisadas, operaciones del día a día.
Tenants
Gestión de workspaces de clientes
| Nombre | Slug | Usuarios | Creado | Estado | |
|---|---|---|---|---|---|
Cargando... | |||||
Administradores
Usuarios con acceso al panel de administración
| Nombre | Workspace | Último acceso | Estado | |
|---|---|---|---|---|
Cargando... | ||||
Modelo canónico del core
Define qué columnas tiene cada entidad del data warehouse. Al añadir una nueva, la tabla core.{entidad} se actualiza automáticamente.
Sources
Sources detectadas automáticamente desde los schemas de Airbyte (tenant_{tenant}_{source}_raw).
| Tenant | Source | Schema | Detectada | Estado | |
|---|---|---|---|---|---|
Cargando... | |||||
Staging logs
Histórico de ejecuciones del motor de staging.
| Inicio | Source | Entidad | Filas | Insertadas | Actualizadas | Duración | Estado |
|---|---|---|---|---|---|---|---|
Cargando... | |||||||
Librería de queries
Plantillas SQL multi-tenant. Construye la query visualmente en Metabase, pégala aquí, deja que el sistema le añada tenant_id, y guárdala. El dashboard de cada cliente las renderiza filtrando por su tenant.
| Nombre | Categoría | Entidad | Gráfico | Estado | Actualizada | |
|---|---|---|---|---|---|---|
Cargando... | ||||||
Nueva query
Pega el SQL desde Metabase, dale a "Adaptar para multi-tenant" para que el sistema añada tenant_id, revisa el resultado y guarda.
{{tenant_id}}. La sustitución por el valor real se hace via parámetros del driver pg, nunca por concatenación.