Añade env de ingesta, SMTP y webhooks + dependencia nodemailer

Variables nuevas (todas opcionales vía zod, sin romper build/demo):
- FUNNEL_API_KEY: clave Bearer del EP de ingesta.
- SMTP_* + EMAIL_FROM: envío de email del presupuesto/enlace.
- PERFIL/WHATSAPP/WHATSAPP_START webhook URLs: señales al flujo externo.
- APP_URL: base para enlaces absolutos.
Helpers emailConfigurado()/perfilWebhookConfigurado()/whatsappWebhookConfigurado()/
whatsappStartConfigurado(). nodemailer como dep directa (stack: Email SMTP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:01:15 +02:00
parent b9dd90f4ef
commit 737496ed89
4 changed files with 88 additions and 0 deletions

View File

@@ -7,3 +7,26 @@ DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
RETELL_API_KEY=""
RETELL_AGENT_ID=""
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
# EP de ingesta del lead (/api/leads/:id/ingesta). Clave compartida que valida al llamante
# externo (Authorization: Bearer ...). Sin ella, el EP responde 401.
FUNNEL_API_KEY=""
# Email (SMTP) para enviar el presupuesto y el enlace al formulario. OPCIONALES: sin SMTP_HOST +
# EMAIL_FROM el envío degrada a no-op (la entrega queda marcada como simulada). Mailhog local:
# SMTP_HOST=localhost SMTP_PORT=1025 (sin user/pass).
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
EMAIL_FROM="" # remitente, p. ej. "Reformas Ejemplo <no-reply@reformix.es>"
# Webhooks salientes hacia el flujo externo (n8n/generador). OPCIONALES: sin URL la señal no se
# manda. PERFIL = perfil completo (generar renders/agente); WHATSAPP = entrega del PDF;
# WHATSAPP_START = arrancar la conversación de WhatsApp con el lead.
PERFIL_WEBHOOK_URL=""
WHATSAPP_WEBHOOK_URL=""
WHATSAPP_START_WEBHOOK_URL=""
# Base pública de la app, para construir enlaces absolutos (enlace al formulario en el email).
APP_URL="http://localhost:3000"

View File

@@ -13,6 +13,7 @@
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
@@ -24,6 +25,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
@@ -2966,6 +2968,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
@@ -7201,6 +7213,15 @@
"node": ">=18"
}
},
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",

View File

@@ -23,6 +23,7 @@
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"nodemailer": "^8.0.10",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
@@ -34,6 +35,7 @@
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",

View File

@@ -12,12 +12,36 @@ const schema = z.object({
RETELL_API_KEY: opcional,
RETELL_AGENT_ID: opcional,
RETELL_FROM_NUMBER: opcional,
// EP de ingesta del lead: clave compartida que valida al llamante externo.
FUNNEL_API_KEY: opcional,
// SMTP para enviar el presupuesto y el enlace al formulario.
SMTP_HOST: opcional,
SMTP_PORT: opcional,
SMTP_USER: opcional,
SMTP_PASS: opcional,
EMAIL_FROM: opcional,
// Webhooks salientes hacia el flujo externo (generación, entrega y arranque de WhatsApp).
PERFIL_WEBHOOK_URL: opcional,
WHATSAPP_WEBHOOK_URL: opcional,
WHATSAPP_START_WEBHOOK_URL: opcional,
// Base pública de la app, para construir enlaces (ej. el enlace al formulario en el email).
APP_URL: opcional,
});
export const env = schema.parse({
RETELL_API_KEY: process.env.RETELL_API_KEY,
RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
RETELL_FROM_NUMBER: process.env.RETELL_FROM_NUMBER,
FUNNEL_API_KEY: process.env.FUNNEL_API_KEY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM: process.env.EMAIL_FROM,
PERFIL_WEBHOOK_URL: process.env.PERFIL_WEBHOOK_URL,
WHATSAPP_WEBHOOK_URL: process.env.WHATSAPP_WEBHOOK_URL,
WHATSAPP_START_WEBHOOK_URL: process.env.WHATSAPP_START_WEBHOOK_URL,
APP_URL: process.env.APP_URL,
});
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
@@ -26,3 +50,21 @@ export const env = schema.parse({
export function retellConfigurado(): boolean {
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
}
// Mínimo para enviar email: host SMTP + remitente. Sin esto el envío degrada a no-op.
export function emailConfigurado(): boolean {
return Boolean(env.SMTP_HOST && env.EMAIL_FROM);
}
// Cada webhook saliente es opcional: si falta su URL, la señal correspondiente no se manda.
export function perfilWebhookConfigurado(): boolean {
return Boolean(env.PERFIL_WEBHOOK_URL);
}
export function whatsappWebhookConfigurado(): boolean {
return Boolean(env.WHATSAPP_WEBHOOK_URL);
}
export function whatsappStartConfigurado(): boolean {
return Boolean(env.WHATSAPP_START_WEBHOOK_URL);
}