diff --git a/.gitignore b/.gitignore index 4993434..3bfb037 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ tests/reporte/ tsconfig.tsbuildinfo test-results/ playwright-report/ +deploy/fspm-live.sh +deploy/.fspm-live.ejecutar.sh diff --git a/README.md b/README.md index 6786d40..693da20 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # FSPM — sitio comercial y micro CRM +**En línea: https://fspm-demo.urieljareth.org** · Panel en `/admin/login`, que +no se enlaza desde el sitio a propósito. Cómo está desplegado y cómo publicar +una versión nueva: [`docs/DESPLIEGUE.md`](docs/DESPLIEGUE.md). + Demostración construida por **Consultoría E3** para Fire Service Plus México. Sitio público orientado a venta consultiva, con un CRM interno que registra cada envío de formulario y de widget de WhatsApp como oportunidad, con su atribución diff --git a/deploy/coolify-servicio.yml b/deploy/coolify-servicio.yml new file mode 100644 index 0000000..4b92c7a --- /dev/null +++ b/deploy/coolify-servicio.yml @@ -0,0 +1,54 @@ +# Compose del servicio en Coolify. +# +# Por qué existe este archivo y no un despliegue git-based normal: +# la API REST de esta instancia de Coolify no expone creación de aplicaciones +# (todas las rutas /applications/* responden 404, verificado en 4.1.2 y 4.3.12), +# y la interfaz web reportaba "No available server". La única ruta de creación +# que sí responde es POST /services con un compose. Como un servicio de Coolify +# no clona un repositorio, la imagen se construye aparte —dentro del LXC donde +# corre Coolify, desde el repo público de Gitea— y aquí solo se consume. +# +# Consecuencia operativa, y hay que tenerla presente: un push a Gitea NO +# redespliega solo. Para publicar una versión nueva hay que reconstruir la +# imagen en el host y reiniciar el servicio. Está documentado en +# docs/DESPLIEGUE.md. +# +# SERVICE_FQDN_WEB_3000 es la convención de Coolify: al declararla, Coolify +# genera el dominio y las etiquetas de Traefik para el puerto 3000 del servicio +# `web`, y se encarga del certificado. + +services: + web: + image: fspm-web:demo + restart: unless-stopped + environment: + - SERVICE_FQDN_WEB_3000 + - NODE_ENV=production + - HOSTNAME=0.0.0.0 + - PORT=3000 + # Ruta absoluta a propósito: una relativa se resuelve contra la carpeta + # del esquema de Prisma y es el clásico "funciona en local, falla aquí". + - DATABASE_URL=file:/app/data/fspm.db + - AUTH_SECRET=${AUTH_SECRET} + # El acceso rápido por perfil queda encendido porque la demo se enseña + # acompañada. Apagarlo es cambiar esto a false y reiniciar. + - DEMO_ACCESO_RAPIDO=true + # Techo de envíos por IP en diez minutos. Veinte frena a un bot y deja + # respirar una demostración con varias personas en la misma red. + - LEADS_MAX_ENVIOS=20 + volumes: + # Aquí vive el SQLite y TIENE que sobrevivir a los redespliegues. Sin + # este volumen la falla es silenciosa: el contenedor arranca con los + # datos sembrados y nadie nota nada hasta que se pierde un lead real. + - fspm-datos:/app/data + healthcheck: + # La sonda consulta la base, así que un volumen no escribible se reporta + # como contenedor enfermo en vez de pasar por sano. + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/salud"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + +volumes: + fspm-datos: diff --git a/deploy/verificar-produccion.mjs b/deploy/verificar-produccion.mjs new file mode 100644 index 0000000..5d4a59f --- /dev/null +++ b/deploy/verificar-produccion.mjs @@ -0,0 +1,51 @@ +import { chromium, webkit } from "@playwright/test"; + +const BASE = "https://fspm-demo.urieljareth.org"; +let fallas = 0; +const ok = (c, m) => { console.log(`${c ? " ok " : "FALLA "} ${m}`); if (!c) fallas++; }; + +for (const [nombre, motor, viewport] of [ + ["chromium 1440", chromium, { width: 1440, height: 900 }], + ["webkit 375", webkit, { width: 375, height: 780 }], +]) { + console.log(`\n=== ${nombre} ===`); + const nav = await motor.launch(); + const pag = await nav.newPage({ viewport, locale: "es-MX" }); + + // sitio público + await pag.goto(BASE + "/", { waitUntil: "domcontentloaded" }); + ok((await pag.locator("h1").count()) === 1, "el inicio tiene un solo h1"); + ok((await pag.locator('a[href^="/admin"]').count()) === 0, "el panel no está enlazado"); + + // la página no se corre de lado + const corre = await pag.evaluate(() => { + window.scrollTo(800, 0); const m = window.scrollX; window.scrollTo(0, 0); return m; + }); + ok(corre <= 2, `el inicio no se desplaza en horizontal (se corrió ${corre}px)`); + + // catálogo filtrado por escenario + await pag.goto(BASE + "/catalogo?escenario=gasolineras", { waitUntil: "domcontentloaded" }); + ok((await pag.locator("h1").count()) === 1, "el catálogo filtrado carga"); + + // Acceso rápido al panel. Se espera `load` y un margen antes de pulsar: el + // botón dispara una acción de servidor y hasta que React no hidrata, el clic + // no hace nada. Con la latencia del dominio público esa carrera se pierde, y + // la primera versión de esta verificación culpó a Cloudflare por eso. + await pag.goto(BASE + "/admin/login", { waitUntil: "load" }); + const perfil = pag.getByRole("button").filter({ hasText: /administrador/i }).first(); + ok(await perfil.isVisible().catch(() => false), "el acceso rápido lista perfiles"); + await pag.waitForTimeout(2500); + await perfil.click(); + await pag.waitForURL(/\/admin(?!\/login)/, { timeout: 30000 }).catch(() => {}); + ok(!/\/admin\/login/.test(pag.url()), `entró al panel (${pag.url().replace(BASE, "")})`); + + // y la sesión persiste en otra página: eso es la cookie Secure funcionando + await pag.goto(BASE + "/admin/oportunidades", { waitUntil: "load" }); + ok(!/\/admin\/login/.test(pag.url()), "la sesión persiste entre páginas"); + const cuerpo = (await pag.locator("body").textContent()) ?? ""; + ok(/OPP-\d{4}-\d{4}/.test(cuerpo), "el listado muestra oportunidades reales"); + + await nav.close(); +} +console.log(fallas === 0 ? "\nTODO EN VERDE EN PRODUCCION" : `\n${fallas} FALLAS EN PRODUCCION`); +process.exit(fallas === 0 ? 0 : 1); diff --git a/docs/DESPLIEGUE.md b/docs/DESPLIEGUE.md index d9add89..2a9eeae 100644 --- a/docs/DESPLIEGUE.md +++ b/docs/DESPLIEGUE.md @@ -3,6 +3,61 @@ Sitio de FSPM con micro CRM. Un solo contenedor: Next.js en modo `standalone` y SQLite en un volumen. No hay base de datos externa. +## Cómo está desplegado hoy + +**En línea:** https://fspm-demo.urieljareth.org + +No es un recurso de Coolify, y eso es deliberado. La API de esta instancia no +expone creación de aplicaciones —las tres rutas `/applications/*` responden 404 +con cuerpo vacío, y `/openapi.yaml` devuelve el HTML del panel—, y al crear un +servicio por `POST /services` la tarea de despliegue quedó colgada sin escribir +nada en `/data/coolify/services/`. La interfaz web reportaba "No available +server" aunque el servidor está sano (verificado por API: alcanzable, usable, +Traefik corriendo, red `coolify` presente, dominio comodín configurado). + +Así que el contenedor corre directamente en el Docker del LXC 102, en la red +`coolify`, con las etiquetas de Traefik que replican el patrón de las +aplicaciones que ya sirven con TLS en ese servidor. Lleva +`coolify.managed=false` para que nadie lo confunda con un recurso del panel. + +- Imagen: `fspm-web:demo`, construida **dentro del LXC 102** desde el repo + público de Gitea. No hay registro de contenedores en juego. +- Contenedor: `fspm-web`, con `--restart unless-stopped`, así que sobrevive a + un reinicio del LXC. +- Volumen: `fspm-datos` montado en `/app/data`. +- Certificado: lo emite el Traefik que ya estaba, con `letsencrypt`. + +**La consecuencia operativa importante:** un push a Gitea **no** redespliega. +Ver "Publicar una versión nueva" abajo. + +### Publicar una versión nueva + +Tres comandos desde el host Proxmox (`ssh -i keys/proxmox_ed25519 root@192.168.0.200`): + +```bash +# 1. traer el código nuevo +pct exec 102 -- git -C /opt/fspm-src pull + +# 2. reconstruir la imagen (los NEXT_PUBLIC_* se hornean aquí) +pct exec 102 -- docker build -t fspm-web:demo --build-arg NEXT_PUBLIC_SITE_URL=https://fspm-demo.urieljareth.org --build-arg NEXT_PUBLIC_WHATSAPP=525540348395 /opt/fspm-src + +# 3. recrear el contenedor — el volumen NO se toca, los leads se conservan +pct exec 102 -- docker rm -f fspm-web +# y volver a correr `deploy/fspm-live.sh` (lleva el AUTH_SECRET sustituido) +``` + +El script `deploy/fspm-live.sh` del repo tiene el `docker run` completo con las +etiquetas de Traefik; el `AUTH_SECRET` va como marcador y se sustituye al +ejecutarlo. + +### Si algún día se puede usar Coolify + +Con el correo del panel web se puede crear la aplicación como **Public +Repository → Dockerfile** apuntando al repo de Gitea, y entonces sí recupera el +redespliegue automático por push. La especificación exacta de campos está en la +sección "Coolify" de abajo. En ese momento hay que **eliminar el contenedor +manual** para que no compitan dos backends por el mismo dominio en Traefik. + ## Variables de entorno | Variable | Requerida | Qué pasa si falta | @@ -110,6 +165,20 @@ curl -H "Authorization: Bearer $COOLIFY_TOKEN" \ "$COOLIFY_API_URL/deploy?uuid=" ``` +## Una carrera que confunde: hidratación y latencia + +El acceso al panel usa una acción de servidor. Hasta que React no hidrata la +página, pulsar el botón **no hace nada y no da error**. Con poca latencia no se +nota; a través de Cloudflare, sí. + +Esto costó un diagnóstico equivocado: la primera verificación automatizada +pulsaba el botón demasiado pronto, fallaba solo por el dominio público y +funcionaba pegándole a Traefik directo, lo que apuntaba a Cloudflare. No era +Cloudflare —los chunks de JavaScript son byte a byte idénticos por los dos +caminos y la hidratación es igual—, era la espera de la prueba. Si alguien +vuelve a ver este síntoma, antes de culpar al proxy conviene esperar `load` más +un margen y volver a medir. + ## Verificación posterior al despliegue Hazla en este orden; cada punto descarta una clase distinta de falla.