Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
This commit is contained in:
@@ -478,6 +478,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
||||
> 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?*
|
||||
> [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme]
|
||||
|
||||
### PDF del presupuesto (documento adjunto)
|
||||
|
||||
Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel. Mantiene el tono orientativo y honesto: deja claro que es una aproximación, sin restarle credibilidad.
|
||||
|
||||
- **Título:** *PRESUPUESTO ORIENTATIVO DE REFORMA*
|
||||
- **Disclaimer (bajo el título):** *El precio final se determinará tras la visita gratuita de [Reformista] en tu casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos para que se acerque lo máximo posible al importe definitivo.*
|
||||
- **Sección render — título:** *Así quedaría tu reforma*
|
||||
- **Render — descripción (se genera según la selección del cliente):** *Recreación con calidad [media] y acabados en [suelo porcelánico, paredes en tono neutro, mobiliario lacado]. Estilo [moderno y luminoso] según tus preferencias.*
|
||||
- **Render — descripción (fallback si faltan materiales o estilo):** *Recreación orientativa de cómo quedaría tu espacio reformado con la calidad [media] seleccionada.*
|
||||
- **Pie de la imagen:** *Render orientativo generado con IA. El resultado real puede variar según los materiales finales y las condiciones de la obra.*
|
||||
|
||||
### WhatsApp follow-up (24h sin respuesta)
|
||||
|
||||
> 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?*
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
|
||||
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
|
||||
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"
|
||||
|
||||
# Retell.ai — agente de voz saliente del funnel B2C. OPCIONALES: sin ellas la llamada no se
|
||||
# dispara y el pipeline sigue en modo simulado. El agente se crea a mano en el panel de Retell.
|
||||
RETELL_API_KEY=""
|
||||
RETELL_AGENT_ID=""
|
||||
RETELL_FROM_NUMBER="" # número de origen en E.164, p. ej. +34910000000
|
||||
|
||||
4
mvp/b2c/package-lock.json
generated
4
mvp/b2c/package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -1437,7 +1438,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7980,7 +7980,6 @@
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
@@ -8024,7 +8023,6 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@ import { getLead } from '@/db/queries';
|
||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
||||
import { TIPO_LABEL } from '@/lib/funnel';
|
||||
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
|
||||
import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
import type { AbstractedPreferences } from '@/lib/voice/preferences';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -25,6 +27,22 @@ export async function GET(
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
const desglose = snapshot?.result ?? null;
|
||||
|
||||
const [logoSrc, imagenSrc] = await Promise.all([
|
||||
resolverImagenPdf(empresa.logoUrl, { formato: 'png', maxAncho: 400 }),
|
||||
resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }),
|
||||
]);
|
||||
const prefs = lead.preferencesSnapshot as AbstractedPreferences | null;
|
||||
const render = imagenSrc
|
||||
? {
|
||||
imagenSrc,
|
||||
descripcion: construirDescripcionRender({
|
||||
calidad: lead.calidadGlobal,
|
||||
materiales: desglose?.materialesRender ?? [],
|
||||
estilo: prefs?.estiloRender ?? [],
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
|
||||
const buffer = await renderToBuffer(
|
||||
PresupuestoDoc({
|
||||
empresa,
|
||||
@@ -34,6 +52,8 @@ export async function GET(
|
||||
fecha: lead.createdAt,
|
||||
},
|
||||
desglose,
|
||||
logoSrc,
|
||||
render,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
28
mvp/b2c/src/lib/env.ts
Normal file
28
mvp/b2c/src/lib/env.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Variables de entorno de integraciones externas, validadas con zod (lo exige CLAUDE.md).
|
||||
// Son OPCIONALES a propósito: sin ellas el funnel sigue en modo simulado, y ni el build ni
|
||||
// la demo dependen de tener claves cargadas.
|
||||
const opcional = z.preprocess(
|
||||
(v) => (v === '' || v === undefined ? undefined : v),
|
||||
z.string().min(1).optional(),
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
RETELL_API_KEY: opcional,
|
||||
RETELL_AGENT_ID: opcional,
|
||||
RETELL_FROM_NUMBER: 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,
|
||||
});
|
||||
|
||||
// Mínimo para lanzar una llamada saliente: clave de API + número de origen. El agente puede
|
||||
// ir ligado al número en el panel; si además se define RETELL_AGENT_ID, se manda como
|
||||
// override_agent_id en cada llamada.
|
||||
export function retellConfigurado(): boolean {
|
||||
return Boolean(env.RETELL_API_KEY && env.RETELL_FROM_NUMBER);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, leadPipelineEventos } from '@/db/schema';
|
||||
import { leads, leadPipelineEventos, tenants } from '@/db/schema';
|
||||
import { getPricingConfigFor, getCatalogFor, getEnvioModeFor } from '@/db/pricing-queries';
|
||||
import { computeBudget } from '@/budget';
|
||||
import type { Lead } from '@/db/schema';
|
||||
import { deterministicExtractor } from '@/lib/voice/extractor';
|
||||
import { mergeIntoBudgetInputs, applyPreferences } from '@/lib/voice/apply';
|
||||
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
||||
import type { RawCallData } from '@/lib/voice/preferences';
|
||||
|
||||
// Render demo por tipo de reforma. No hay generación IA real en esta fase (keyless):
|
||||
@@ -60,6 +61,13 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) throw new Error('Lead no encontrado.');
|
||||
|
||||
const [tenant] = await db
|
||||
.select({ nombreEmpresa: tenants.nombreEmpresa })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, lead.tenantId))
|
||||
.limit(1);
|
||||
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
||||
|
||||
const tipo = lead.tipoReforma ?? 'otro';
|
||||
|
||||
// Paso 4: pre-llamada (SMS + WhatsApp)
|
||||
@@ -69,13 +77,24 @@ export async function procesarLead(leadId: string): Promise<void> {
|
||||
metadata: { via: ['sms', 'whatsapp'], simulado: true },
|
||||
});
|
||||
|
||||
// Paso 5: llamada del agente IA completada
|
||||
// Paso 5: llamada del agente IA. Si Retell está configurado se lanza una llamada saliente
|
||||
// REAL (el móvil del lead suena y el agente habla con sus variables); el render y el
|
||||
// presupuesto se siguen generando con los datos del formulario (Arquitectura A de la demo).
|
||||
const llamada = await iniciarLlamadaSaliente({
|
||||
telefono: lead.telefono,
|
||||
variables: construirVariablesLlamada({ nombreEmpresa }, lead),
|
||||
});
|
||||
const transcripcion = construirTranscripcion(lead);
|
||||
const entidades = construirEntidades(lead);
|
||||
await db.insert(leadPipelineEventos).values({
|
||||
leadId,
|
||||
stage: 'llamada_completada',
|
||||
metadata: { simulado: true, duracionSeg: 95 },
|
||||
metadata: {
|
||||
simulado: !llamada,
|
||||
real: Boolean(llamada),
|
||||
retellCallId: llamada?.callId,
|
||||
duracionSeg: 95,
|
||||
},
|
||||
});
|
||||
|
||||
// Paso 6a: render IA generado
|
||||
|
||||
@@ -131,6 +131,12 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' },
|
||||
disclaimer: { marginTop: 6, fontSize: 8.5, color: COLOR.gray600, lineHeight: 1.45 },
|
||||
renderSection: { marginTop: 26 },
|
||||
renderTitle: { fontSize: 11, fontFamily: 'Helvetica-Bold', color: COLOR.black, marginBottom: 8 },
|
||||
renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
|
||||
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
|
||||
renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 },
|
||||
});
|
||||
|
||||
const fmtEuros = (cents: number) =>
|
||||
@@ -148,9 +154,18 @@ export type PresupuestoDocProps = {
|
||||
cliente: { nombre: string; telefono: string; provincia: string | null };
|
||||
reforma: { tipoLabel: string; fecha: Date };
|
||||
desglose: BudgetResult | null;
|
||||
logoSrc?: string | null;
|
||||
render?: { imagenSrc: string; descripcion: string } | null;
|
||||
};
|
||||
|
||||
export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) {
|
||||
export function PresupuestoDoc({
|
||||
empresa,
|
||||
cliente,
|
||||
reforma,
|
||||
desglose,
|
||||
logoSrc,
|
||||
render,
|
||||
}: PresupuestoDocProps) {
|
||||
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
@@ -166,13 +181,18 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue
|
||||
{empresa.direccion ? <Text style={styles.empresaDato}>{empresa.direccion}</Text> : null}
|
||||
{contacto ? <Text style={styles.empresaDato}>{contacto}</Text> : null}
|
||||
</View>
|
||||
{empresa.logoUrl ? (
|
||||
{logoSrc ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
|
||||
<Image src={empresa.logoUrl} style={styles.logo} />
|
||||
<Image src={logoSrc} style={styles.logo} />
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text style={styles.docTitle}>PRESUPUESTO ORIENTATIVO DE REFORMA</Text>
|
||||
<Text style={styles.disclaimer}>
|
||||
El precio final se determinará tras la visita gratuita de {empresa.nombreEmpresa} en tu
|
||||
casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos
|
||||
para que se acerque lo máximo posible al importe definitivo.
|
||||
</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View>
|
||||
@@ -237,6 +257,19 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue
|
||||
<Text style={styles.empty}>Este lead aún no tiene presupuesto calculado.</Text>
|
||||
)}
|
||||
|
||||
{render ? (
|
||||
<View style={styles.renderSection} wrap={false}>
|
||||
<Text style={styles.renderTitle}>Así quedaría tu reforma</Text>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
|
||||
<Image src={render.imagenSrc} style={styles.renderImage} />
|
||||
<Text style={styles.renderDesc}>{render.descripcion}</Text>
|
||||
<Text style={styles.renderFootnote}>
|
||||
Render orientativo generado con IA. El resultado real puede variar según los
|
||||
materiales finales y las condiciones de la obra.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={styles.footer} fixed>
|
||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||
{' · '}
|
||||
|
||||
109
mvp/b2c/src/lib/pdf/render-info.ts
Normal file
109
mvp/b2c/src/lib/pdf/render-info.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import type { Calidad } from '@/budget/types';
|
||||
import { CALIDAD_LABEL } from '@/lib/funnel';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
// @react-pdf solo pinta PNG y JPEG. Cualquier otro formato (WebP de nuestros uploads y
|
||||
// assets, SVG de los logos…) se rasteriza con sharp; si no, la imagen se descarta en
|
||||
// silencio y el PDF sale sin ella. El render va a JPEG (foto, sin transparencia → ligero)
|
||||
// y el logo a PNG (conserva transparencia).
|
||||
const PASSTHROUGH = new Set(['image/png', 'image/jpeg']);
|
||||
|
||||
type OpcionesImagen = { formato?: 'png' | 'jpeg'; maxAncho?: number };
|
||||
|
||||
async function bufferADataUri(buf: Buffer, mime: string, opts: OpcionesImagen): Promise<string> {
|
||||
if (PASSTHROUGH.has(mime)) {
|
||||
return `data:${mime};base64,${buf.toString('base64')}`;
|
||||
}
|
||||
const pipe = sharp(buf, { density: 200 }).resize({
|
||||
width: opts.maxAncho ?? 1400,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
if (opts.formato === 'jpeg') {
|
||||
const jpeg = await pipe.flatten({ background: '#ffffff' }).jpeg({ quality: 80 }).toBuffer();
|
||||
return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
|
||||
}
|
||||
const png = await pipe.png().toBuffer();
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
|
||||
// Convierte el origen de una imagen (renderUrl, logoUrl) en un data URI PNG/JPEG que
|
||||
// @react-pdf pueda incrustar. Acepta data URIs, rutas relativas a /public y URLs http(s).
|
||||
export async function resolverImagenPdf(
|
||||
url: string | null,
|
||||
opts: OpcionesImagen = {}
|
||||
): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
|
||||
if (url.startsWith('data:')) {
|
||||
const coma = url.indexOf(',');
|
||||
if (coma === -1) return null;
|
||||
const cabecera = url.slice(5, coma);
|
||||
const mime = cabecera.split(';')[0] || 'application/octet-stream';
|
||||
const datos = url.slice(coma + 1);
|
||||
const buf = /;base64/i.test(cabecera)
|
||||
? Buffer.from(datos, 'base64')
|
||||
: Buffer.from(decodeURIComponent(datos));
|
||||
try {
|
||||
return await bufferADataUri(buf, mime, opts);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// La pipeline de render entrega PNG/JPEG; las URLs remotas se pasan tal cual.
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
|
||||
if (!url.startsWith('/')) return null;
|
||||
const publicDir = path.join(process.cwd(), 'public');
|
||||
const abs = path.normalize(path.join(publicDir, url));
|
||||
if (abs !== publicDir && !abs.startsWith(publicDir + path.sep)) return null;
|
||||
|
||||
const mime = MIME[path.extname(abs).toLowerCase()];
|
||||
if (!mime) return null;
|
||||
try {
|
||||
return await bufferADataUri(await readFile(abs), mime, opts);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listaNatural(items: string[]): string {
|
||||
if (items.length <= 1) return items[0] ?? '';
|
||||
return `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
// Describe el render combinando los acabados (materialesRender del desglose) con el
|
||||
// estilo detectado en la llamada. Si falta cualquiera de los dos, degrada con elegancia.
|
||||
export function construirDescripcionRender(opts: {
|
||||
calidad: Calidad | null;
|
||||
materiales: string[];
|
||||
estilo: string[];
|
||||
}): string {
|
||||
const calidad = CALIDAD_LABEL[opts.calidad ?? 'media'].toLowerCase();
|
||||
const materiales = opts.materiales.map((s) => s.trim()).filter(Boolean).slice(0, 4);
|
||||
const estilo = opts.estilo.map((s) => s.trim()).filter(Boolean).slice(0, 3);
|
||||
|
||||
if (materiales.length === 0 && estilo.length === 0) {
|
||||
return `Recreación orientativa de cómo quedaría tu espacio reformado con la ${calidad} seleccionada.`;
|
||||
}
|
||||
|
||||
const partes = [
|
||||
materiales.length > 0
|
||||
? `Recreación con ${calidad} y acabados en ${listaNatural(materiales)}.`
|
||||
: `Recreación con ${calidad}.`,
|
||||
];
|
||||
if (estilo.length > 0) {
|
||||
partes.push(`Estilo ${listaNatural(estilo)} según tus preferencias.`);
|
||||
}
|
||||
return partes.join(' ');
|
||||
}
|
||||
77
mvp/b2c/src/lib/voice/retell.ts
Normal file
77
mvp/b2c/src/lib/voice/retell.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { env, retellConfigurado } from '@/lib/env';
|
||||
import { TIPO_LABEL } from '@/lib/funnel';
|
||||
import type { Lead } from '@/db/schema';
|
||||
|
||||
const CREATE_PHONE_CALL_URL = 'https://api.retellai.com/v2/create-phone-call';
|
||||
|
||||
// Normaliza un teléfono español a E.164 (+34XXXXXXXXX), que es lo que exige Retell.
|
||||
// Acepta espacios, guiones, paréntesis y los prefijos +34 / 0034 / 34 ya puestos.
|
||||
export function normalizarTelefonoEs(raw: string): string | null {
|
||||
const limpio = raw.replace(/[\s\-().]/g, '');
|
||||
if (/^\+[1-9]\d{7,14}$/.test(limpio)) return limpio;
|
||||
if (/^0034\d{9}$/.test(limpio)) return `+${limpio.slice(2)}`;
|
||||
if (/^34\d{9}$/.test(limpio)) return `+${limpio}`;
|
||||
if (/^[6789]\d{8}$/.test(limpio)) return `+34${limpio}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type VariablesLlamada = Record<string, string>;
|
||||
|
||||
// Dynamic variables que el agente de Retell inserta en su prompt ({{empresa_nombre}}, etc.).
|
||||
// Solo los datos que el script "habla": empresa y nombre de pila del cliente; tipo de reforma
|
||||
// y provincia van como contexto opcional para que el agente confirme en vez de preguntar a frío.
|
||||
export function construirVariablesLlamada(
|
||||
empresa: { nombreEmpresa: string },
|
||||
lead: Pick<Lead, 'nombre' | 'tipoReforma' | 'provincia'>,
|
||||
): VariablesLlamada {
|
||||
const vars: VariablesLlamada = {
|
||||
empresa_nombre: empresa.nombreEmpresa,
|
||||
cliente_nombre: lead.nombre.split(' ')[0],
|
||||
};
|
||||
if (lead.tipoReforma) vars.tipo_reforma = TIPO_LABEL[lead.tipoReforma].toLowerCase();
|
||||
if (lead.provincia) vars.provincia = lead.provincia;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export type ResultadoLlamada = { callId: string };
|
||||
|
||||
// Inicia una llamada saliente con el agente de Retell. Best-effort para la demo (Arquitectura A):
|
||||
// el POST vuelve en cuanto Retell acepta la llamada (el móvil del lead empieza a sonar) y el
|
||||
// pipeline continúa generando el entregable. Si Retell no está configurado, el teléfono no es
|
||||
// válido o la API falla, devuelve null y NO lanza — el funnel sigue en modo simulado.
|
||||
export async function iniciarLlamadaSaliente(opts: {
|
||||
telefono: string;
|
||||
variables: VariablesLlamada;
|
||||
}): Promise<ResultadoLlamada | null> {
|
||||
if (!retellConfigurado()) return null;
|
||||
|
||||
const toNumber = normalizarTelefonoEs(opts.telefono);
|
||||
if (!toNumber) return null;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
from_number: env.RETELL_FROM_NUMBER,
|
||||
to_number: toNumber,
|
||||
retell_llm_dynamic_variables: opts.variables,
|
||||
};
|
||||
if (env.RETELL_AGENT_ID) body.override_agent_id = env.RETELL_AGENT_ID;
|
||||
|
||||
try {
|
||||
const res = await fetch(CREATE_PHONE_CALL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RETELL_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Retell create-phone-call ${res.status}: ${await res.text()}`);
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as { call_id?: string };
|
||||
return data.call_id ? { callId: data.call_id } : null;
|
||||
} catch (err) {
|
||||
console.error('Retell create-phone-call error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
63
mvp/b2c/tests/voice/retell.test.ts
Normal file
63
mvp/b2c/tests/voice/retell.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizarTelefonoEs, construirVariablesLlamada } from '@/lib/voice/retell';
|
||||
|
||||
describe('normalizarTelefonoEs', () => {
|
||||
it('añade prefijo +34 a un móvil nacional de 9 dígitos', () => {
|
||||
expect(normalizarTelefonoEs('612345678')).toBe('+34612345678');
|
||||
});
|
||||
|
||||
it('acepta fijos nacionales (empiezan por 8/9)', () => {
|
||||
expect(normalizarTelefonoEs('912345678')).toBe('+34912345678');
|
||||
});
|
||||
|
||||
it('limpia espacios, guiones y paréntesis', () => {
|
||||
expect(normalizarTelefonoEs('612 34 56 78')).toBe('+34612345678');
|
||||
expect(normalizarTelefonoEs('+34 612-345-678')).toBe('+34612345678');
|
||||
});
|
||||
|
||||
it('respeta un E.164 ya formado', () => {
|
||||
expect(normalizarTelefonoEs('+34612345678')).toBe('+34612345678');
|
||||
});
|
||||
|
||||
it('normaliza los prefijos 0034 y 34 sin +', () => {
|
||||
expect(normalizarTelefonoEs('0034612345678')).toBe('+34612345678');
|
||||
expect(normalizarTelefonoEs('34612345678')).toBe('+34612345678');
|
||||
});
|
||||
|
||||
it('devuelve null si no es un teléfono válido', () => {
|
||||
expect(normalizarTelefonoEs('123')).toBeNull();
|
||||
expect(normalizarTelefonoEs('hola')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('construirVariablesLlamada', () => {
|
||||
it('incluye siempre empresa_nombre y el nombre de pila del cliente', () => {
|
||||
const vars = construirVariablesLlamada(
|
||||
{ nombreEmpresa: 'Reformas Ejemplo' },
|
||||
{ nombre: 'Ana Pérez', tipoReforma: 'cocina', provincia: 'Madrid' },
|
||||
);
|
||||
expect(vars.empresa_nombre).toBe('Reformas Ejemplo');
|
||||
expect(vars.cliente_nombre).toBe('Ana');
|
||||
expect(vars.tipo_reforma).toBe('cocina');
|
||||
expect(vars.provincia).toBe('Madrid');
|
||||
});
|
||||
|
||||
it('omite tipo_reforma y provincia cuando faltan', () => {
|
||||
const vars = construirVariablesLlamada(
|
||||
{ nombreEmpresa: 'Reformas Ejemplo' },
|
||||
{ nombre: 'Luis', tipoReforma: null, provincia: null },
|
||||
);
|
||||
expect(vars.empresa_nombre).toBe('Reformas Ejemplo');
|
||||
expect(vars.cliente_nombre).toBe('Luis');
|
||||
expect('tipo_reforma' in vars).toBe(false);
|
||||
expect('provincia' in vars).toBe(false);
|
||||
});
|
||||
|
||||
it('usa la etiqueta en minúscula para tipo_reforma', () => {
|
||||
const vars = construirVariablesLlamada(
|
||||
{ nombreEmpresa: 'X' },
|
||||
{ nombre: 'Eva', tipoReforma: 'integral', provincia: null },
|
||||
);
|
||||
expect(vars.tipo_reforma).toBe('reforma integral');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user