feat: sitio comercial FSPM con micro CRM interno

Sitio público orientado a venta consultiva e implementación de las
recomendaciones de la auditoría de marca y mercado:

- Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador
  de riesgo en el héroe, contra el hallazgo de que el visitante tenía que
  inferir qué solución le correspondía.
- Cinco páginas por segmento prioritario y catálogo comparable por clase
  de fuego, capacidad y agente.
- Los claims sin evidencia por modelo quedan aislados y se presentan como
  compromiso de documentación, nunca como característica del producto.
- SEO técnico completo: metadatos por página, sitemap derivado de los
  datos, JSON-LD, iconos y tarjeta social generadas.

Micro CRM:

- Captura por formulario embebido y por widget de WhatsApp que registra
  antes de abrir la conversación.
- Atribución de primer toque que sobrevive a la navegación.
- Deduplicación de contacto; un mismo contacto acumula oportunidades.
- Motor de asignación por reglas con reparto por turno de respaldo.
- Panel con dos roles de alcance distinto, tablero analítico, bitácora,
  pedidos y reglas. No enlazado desde el sitio público y con noindex.

Empaquetado en Docker multietapa con la base de demostración horneada en
la imagen y el volumen de datos separado, para que los leads capturados
sobrevivan a los redespliegues.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-08-28 00:07:20 -06:00
co-authored by Claude Opus 5
parent 122e06a4bb
commit 01b817d098
649 changed files with 26690 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
# check=skip=SecretsUsedInArgOrEnv
# La directiva de arriba tiene que ser la primera línea del archivo. Silencia la
# advertencia de BuildKit por el AUTH_SECRET de la etapa de construcción, que es
# un relleno para que `next build` no falle: no llega a la imagen final ni se
# usa en ejecución. Sin esto, cada construcción sale con una advertencia y se
# acaba aprendiendo a ignorar también las advertencias de verdad.
# =============================================================================
# FSPM — imagen de producción
#
# Tres etapas sobre node:22-alpine, aprovechando output: "standalone" de
# next.config.ts. La idea de cada etapa:
#
# deps instala las dependencias en su propia capa, así un cambio de código
# no vuelve a bajar node_modules.
# build genera el cliente de Prisma, hornea una base de demostración y
# compila Next.
# runner imagen mínima que solo sabe arrancar el servidor, con usuario no
# root y la base de datos en un volumen.
#
# OJO CON LAS VARIABLES NEXT_PUBLIC_*
# Next las sustituye literalmente durante `next build`, no las lee al arrancar.
# Por eso NEXT_PUBLIC_SITE_URL y NEXT_PUBLIC_WHATSAPP van como ARG de
# construcción: cambiar el dominio exige reconstruir la imagen, no basta con
# editar el entorno del contenedor. Está explicado en docs/DESPLIEGUE.md.
# =============================================================================
# --- etapa 1: dependencias -----------------------------------------------------
FROM node:22-alpine AS deps
# libc6-compat y openssl: Prisma los necesita en Alpine para sus motores.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
# El esquema entra antes de npm ci porque el postinstall de @prisma/client
# intenta generar el cliente y sin esquema deja una advertencia.
COPY package.json package-lock.json ./
COPY prisma/schema.prisma ./prisma/schema.prisma
# Aquí NO se define NODE_ENV=production: hacen falta las dependencias de
# desarrollo (prisma, tsx, typescript, tailwind) para construir y para sembrar.
RUN npm ci
# CLI de Prisma aislada, con sus dependencias propias resueltas en su propio
# árbol. El punto de entrada del contenedor la necesita para aplicar el esquema
# al arrancar, y copiar paquetes suelto por suelto no funciona: la CLI arrastra
# dependencias transitivas (effect, entre otras) que no son evidentes.
# La versión se lee de package.json, así que no puede desincronizarse.
#
# Después de instalarla se le quitan dos cosas que no va a usar nunca: los
# mapas de código fuente y el motor de consultas. Esta CLI solo ejecuta
# `db push`, que usa el motor de esquema; el motor de consultas que la
# aplicación necesita es el que viene dentro de la salida standalone. Son unos
# 40 MB menos en la imagen final.
RUN npm install --no-save --no-audit --no-fund --prefix /prisma-cli \
"prisma@$(node -p "require('/app/package.json').devDependencies.prisma")" \
&& find /prisma-cli/node_modules -type f -name "*.map" -delete \
&& rm -f /prisma-cli/node_modules/@prisma/engines/libquery_engine-*.so.node
# --- etapa 2: construcción -----------------------------------------------------
FROM node:22-alpine AS build
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Valores por omisión iguales a .env.example: si nadie pasa --build-arg, la
# imagen queda coherente en vez de quedar con la cadena vacía, que rompería
# `new URL(SITIO_URL)` en el layout raíz.
ARG NEXT_PUBLIC_SITE_URL="https://fspm-demo.urieljareth.org"
ARG NEXT_PUBLIC_WHATSAPP="525540348395"
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_WHATSAPP=${NEXT_PUBLIC_WHATSAPP}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# `next build` y `prisma generate` exigen que DATABASE_URL exista, aunque no se
# conecten a la base real. Este valor es solo de compilación: apunta a la base
# de demostración que se hornea aquí mismo y nunca se usa en ejecución.
ENV DATABASE_URL="file:/semilla/fspm.db"
# Igual con el secreto de sesión: existe para que el build no falle. El
# contenedor recibe el suyo por entorno.
ENV AUTH_SECRET="secreto-de-compilacion-sin-valor-en-ejecucion"
RUN npx prisma generate
# --- base de demostración horneada en la imagen ---
#
# El seed es DESTRUCTIVO: `prisma/seed.ts` hace deleteMany() de todas las tablas
# antes de reconstruir. Ejecutarlo dentro del contenedor de producción borraría
# los leads capturados en cada reinicio. Por eso se ejecuta aquí, una sola vez,
# contra una base que queda dentro de la imagen; el contenedor solo la copia al
# volumen cuando el volumen está vacío. Ese es el motivo real por el que `tsx`,
# que es dependencia de desarrollo, nunca se instala en la imagen final.
RUN mkdir -p /semilla \
&& npx prisma db push --skip-generate \
&& if [ -f prisma/seed.ts ]; then \
npx tsx prisma/seed.ts; \
else \
echo "AVISO: no existe prisma/seed.ts. La imagen queda con el esquema pero sin datos de demostración."; \
fi
RUN npm run build
# --- etapa 3: ejecución --------------------------------------------------------
FROM node:22-alpine AS runner
# openssl y libc6-compat también aquí: el cliente de Prisma los carga en
# ejecución, no solo al construir.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
# El aviso de "hay una versión nueva de Prisma" sale en cada arranque y solo
# entrena a nadie a leer los registros. La versión se actualiza cuando el
# proyecto lo decida, no cuando el contenedor lo sugiera.
ENV PRISMA_HIDE_UPDATE_MESSAGE=true
# Ruta absoluta a propósito. Una relativa como "file:../data/fspm.db" se
# resuelve contra la carpeta del esquema y es justo el tipo de detalle que
# funciona en local y falla en el contenedor.
ENV DATABASE_URL="file:/app/data/fspm.db"
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# CLI de Prisma, en su propio árbol para que el punto de entrada pueda aplicar
# el esquema al arrancar. Va en /app/prisma-cli, aparte del node_modules de la
# aplicación: así no puede pisar el @prisma/client generado que trae la salida
# standalone, que es el que la aplicación usa en ejecución.
COPY --from=deps /prisma-cli/node_modules ./prisma-cli/node_modules
# El esquema tiene que estar presente en ejecución: `prisma db push` lo lee.
COPY --from=build /app/prisma/schema.prisma ./prisma/schema.prisma
# Base de demostración horneada. Solo se copia al volumen si el volumen no
# tiene una base todavía.
COPY --from=build /semilla/fspm.db ./semilla/fspm.db
# Salida de Next. El standalone va al final para que su @prisma/client, que es
# el que la aplicación usa, gane sobre cualquier otro.
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# El directorio de datos nace con dueño nextjs: si el volumen se crea vacío,
# Docker hereda estos permisos y el proceso no root puede escribir la base.
RUN mkdir -p /app/data \
&& chown -R nextjs:nodejs /app/data /app/semilla /app/.next
USER nextjs
# Aquí vive el SQLite y TIENE que sobrevivir a los redespliegues. Si no se
# monta, cada nueva versión de la imagen empieza con la base de demostración y
# los leads capturados se pierden. Está explicado en docs/DESPLIEGUE.md.
VOLUME ["/app/data"]
EXPOSE 3000
# La sonda toca /api/salud, que a su vez consulta la base: así un volumen no
# escribible se reporta como contenedor enfermo en vez de pasar por sano.
# --start-period cubre el `prisma db push` del arranque más el boot de Next.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --quiet --tries=1 --spider "http://127.0.0.1:${PORT}/api/salud" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "server.js"]