diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..73d9aec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +node_modules +.next +.git +.claude +api +docs +cotizador +*.xlsx +*.bat +.env* +!.env.example +*.tsbuildinfo +*.tmp.* +Dockerfile +docker-compose*.yml +README.md +AGENTS.md +CLAUDE.md +src/generated diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb39785 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Cotizador E3 — variables de entorno +# Copia este archivo a .env (local) o configúralas en Coolify (producción). + +# ── Base de datos (compartida por Next.js y el API Python) ── +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=cotizador_e3 + +# Solo la usa el CLI de Prisma (migraciones). Debe coincidir con DB_*. +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cotizador_e3" + +# ── Auth ── +# OBLIGATORIA. Genera una con: openssl rand -base64 32 +JWT_SECRET= + +# ── API Python (api/) — clave para agentes de IA / n8n ── +API_KEY= + +# ── Seed (catálogo + usuarios). En producción: RUN_SEED=true solo el primer despliegue ── +RUN_SEED=false +SEED_ADMIN_EMAIL=admin@e3.local +SEED_ADMIN_PASSWORD= +SEED_ADMIN_NAME=Administrador +# Asesor opcional: solo se crea si defines el email +SEED_ASESOR_EMAIL= +SEED_ASESOR_PASSWORD= +SEED_ASESOR_NAME= diff --git a/AGENTS.md b/AGENTS.md index 59593bd..fae087d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,15 @@ - **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly). - **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical. +## Deployment (Coolify) + +- Production stack: `docker-compose.coolify.yml` (postgres + web + api). In Coolify set **Docker Compose Location** to `/docker-compose.coolify.yml`. Local dev keeps using `docker-compose.yml` + `start.bat`. +- The `web` container runs `prisma migrate deploy` on every start, and the seed only when `RUN_SEED=true`. Seed users/passwords come from `SEED_*` env vars (see `.env.example`) — `prisma/seed.ts` never overwrites an existing password unless the env var is set. +- `DATABASE_URL` is only used by the Prisma CLI (migrations); the apps use individual `DB_*` vars. Both must point to the same DB. +- Healthchecks: web `GET /api/health` (public in middleware, pings DB), api `GET /health`. +- Root `Dockerfile` builds Next.js with `JWT_SECRET=placeholder-build-only` (auth.ts throws at import without it; all pages are force-dynamic so nothing gets baked). +- `*.xlsx` is gitignored (business files must never be committed). + ## Architecture ``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ae29fdc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Cotizador E3 — Next.js (web) +# Multi-stage build. El runner conserva node_modules completo para que +# `prisma migrate deploy` y el seed (tsx) funcionen dentro del contenedor. + +FROM node:22-slim AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +# ── Dependencias ── +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Build ── +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Placeholder solo para el build: src/lib/auth.ts lanza error al importarse +# sin JWT_SECRET. Todas las páginas son force-dynamic, nada queda horneado. +ENV JWT_SECRET=placeholder-build-only +RUN npx prisma generate && npm run build + +# ── Runtime ── +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/src/generated ./src/generated +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts +COPY --from=builder /app/next.config.ts ./next.config.ts +COPY --from=builder /app/tsconfig.json ./tsconfig.json +COPY --from=builder /app/package.json ./package.json + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=5 \ + CMD node -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +# Migraciones primero, seed opcional (RUN_SEED=true), luego el servidor. +CMD ["sh", "-c", "npx prisma migrate deploy && if [ \"$RUN_SEED\" = \"true\" ]; then npx tsx prisma/seed.ts; fi && exec node_modules/.bin/next start -p ${PORT:-3000}"] diff --git a/README.md b/README.md index e215bc4..999935f 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,70 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Cotizador E3 -## Getting Started +Sistema de cotizaciones de Consultoría E3. Dos backends sobre la misma base de datos PostgreSQL: -First, run the development server: +- **Web (Next.js 16)** — `src/`, puerto 3000. UI completa: cotizaciones, clientes, catálogo, configuración, export PDF/Excel. Prisma es dueño del esquema y las migraciones. +- **API (FastAPI + MCP)** — `api/`, puerto 8000. REST para integraciones (n8n, agentes de IA) con servidor MCP en `/mcp`. Referencia completa en `api/COTIZADOR_API_SKILL.md`. + +## Desarrollo local (Windows) + +Requisitos: Node 20+, Docker Desktop. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +# 1. Copia las variables de entorno +copy .env.example .env # y define JWT_SECRET + +# 2. Levanta PostgreSQL + API Python y el dev server de Next.js +start.bat ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +`start.bat` levanta el compose local (`docker-compose.yml`), aplica migraciones y arranca `npm run dev`. Para detener todo: `stop.bat`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Comandos útiles: -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +| Comando | Propósito | +|---------|-----------| +| `npm run dev` | Dev server Next.js (puerto 3000) | +| `npm run build` | Build de producción | +| `npx prisma migrate dev` | Crear/aplicar migración | +| `npx tsx prisma/seed.ts` | Seed (idempotente; usuarios vía `SEED_*`) | +| `npx prisma studio` | GUI de la base de datos | -## Learn More +## Despliegue en Coolify -To learn more about Next.js, take a look at the following resources: +El stack de producción está en [docker-compose.coolify.yml](docker-compose.coolify.yml): `postgres` + `web` (Next.js) + `api` (FastAPI). El contenedor `web` aplica las migraciones de Prisma automáticamente en cada arranque. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +### Pasos -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +1. **Sube el repo a GitHub** (privado recomendado). +2. En Coolify: **+ New Resource → Docker Compose**, conecta el repo y la rama. +3. En la configuración del recurso, define **Docker Compose Location** = `/docker-compose.coolify.yml`. +4. Coolify detecta los servicios y asigna dominio a `web` (puerto 3000) y `api` (puerto 8000) vía las variables `SERVICE_FQDN_*` — configura los dominios deseados en la UI. +5. Define las variables de entorno en Coolify: -## Deploy on Vercel +| Variable | Obligatoria | Notas | +|----------|-------------|-------| +| `JWT_SECRET` | Sí | `openssl rand -base64 32` | +| `API_KEY` | Sí (para el API) | Clave para agentes/n8n | +| `DB_PASSWORD` | Recomendada | Password de PostgreSQL (default `postgres`) | +| `RUN_SEED` | Primer deploy | `true` solo la primera vez; luego `false` | +| `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` | Primer deploy | Usuario admin inicial | +| `SEED_ASESOR_EMAIL` / `SEED_ASESOR_PASSWORD` / `SEED_ASESOR_NAME` | No | Asesor opcional | -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +6. **Deploy.** Orden de arranque: `postgres` (healthy) → `web` (migra + seed + sirve) → `api`. +7. Después del primer despliegue exitoso, cambia `RUN_SEED` a `false` y redeploya (el seed es idempotente, pero no hace falta correrlo cada vez). -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### Healthchecks + +- Web: `GET /api/health` (verifica también la conexión a la BD) +- API: `GET /health` + +### Notas + +- No expongas el puerto 5432: los servicios se comunican por la red interna del compose. +- El volumen `postgres_data` persiste la base de datos entre deploys. No lo borres. +- El servidor MCP queda en `https:///mcp` (auth por `X-API-Key`). +- La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad. + +## Arquitectura + +Ver [AGENTS.md](AGENTS.md) para convenciones, gotchas de Prisma 7 / PDFKit / Next 16 y el modelo de datos. diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..3bd7460 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +*.pyo +.venv +venv +.pytest_cache +.ruff_cache +.env* +Dockerfile diff --git a/api/Dockerfile b/api/Dockerfile index b3a66cf..3edfb4d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,6 +2,9 @@ FROM python:3.12-slim WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -9,4 +12,8 @@ COPY . . EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=5 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" + +# --proxy-headers: necesario detrás del reverse proxy (Traefik/Coolify) +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml new file mode 100644 index 0000000..1f4541b --- /dev/null +++ b/docker-compose.coolify.yml @@ -0,0 +1,89 @@ +# Cotizador E3 — stack de producción para Coolify +# +# En Coolify: crear recurso "Docker Compose" apuntando a este repo y configurar +# "Docker Compose Location" = /docker-compose.coolify.yml +# +# Las variables SERVICE_FQDN_* las resuelve Coolify automáticamente (asignan +# dominio a cada servicio). Define JWT_SECRET, API_KEY, DB_PASSWORD y las +# SEED_* en el panel de variables de entorno de Coolify. +# +# Para desarrollo local sigue usándose docker-compose.yml + start.bat. + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-cotizador_e3} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + + web: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + environment: + - SERVICE_FQDN_WEB_3000 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD:-postgres} + - DB_NAME=${DB_NAME:-cotizador_e3} + # Solo la usa el CLI de Prisma (migrate deploy); debe coincidir con DB_* + - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-cotizador_e3} + - JWT_SECRET=${JWT_SECRET} + # Seed inicial: pon RUN_SEED=true solo en el primer despliegue + - RUN_SEED=${RUN_SEED:-false} + - SEED_ADMIN_EMAIL=${SEED_ADMIN_EMAIL:-admin@e3.local} + - SEED_ADMIN_PASSWORD=${SEED_ADMIN_PASSWORD} + - SEED_ADMIN_NAME=${SEED_ADMIN_NAME:-Administrador} + - SEED_ASESOR_EMAIL=${SEED_ASESOR_EMAIL} + - SEED_ASESOR_PASSWORD=${SEED_ASESOR_PASSWORD} + - SEED_ASESOR_NAME=${SEED_ASESOR_NAME} + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 5 + + api: + build: + context: ./api + dockerfile: Dockerfile + restart: unless-stopped + environment: + - SERVICE_FQDN_API_8000 + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD:-postgres} + - DB_NAME=${DB_NAME:-cotizador_e3} + - API_KEY=${API_KEY} + - JWT_SECRET=${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy + # web aplica las migraciones de Prisma; el api usa las mismas tablas + web: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"] + interval: 30s + timeout: 10s + start_period: 30s + retries: 5 + +volumes: + postgres_data: diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..e184903 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + await prisma.$queryRaw`SELECT 1`; + return NextResponse.json({ status: "ok", database: "ok" }); + } catch { + return NextResponse.json( + { status: "error", database: "unreachable" }, + { status: 503 } + ); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index b80b769..471978f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { verifySession } from "@/lib/auth"; -const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/logout"]; +const PUBLIC_PATHS = [ + "/login", + "/api/auth/login", + "/api/auth/logout", + "/api/health", +]; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl;