Acerca el cálculo a tarifas de mercado sin rehacer el modelo lineal €/m²: - Impermeabilización como partida propia en zonas húmedas (cocina/baño/integral) - Extras fijos que no escalan con m²: boletín (siempre), tuberías (piso anterior a 2000) y cambio de distribución (mover inodoro/ducha/bañera) - Intensidad por tipo en fontanería/electricidad (baseline cocina) para que un integral no escale como un baño - Factor de zona por provincia en tramos (Madrid/BCN 1.40, islas 1.30, capitales 1.20, rural 0.85, resto 1.00) - 2 preguntas nuevas en el formulario del cliente para disparar los extras - Panel de precios: campo de impermeabilización + sección de extras fijos - Seed recalibrado (mano de obra, extras, catálogo suelo/pared) - Migración 0009 (leads.anterior_a_2000, leads.cambio_distribucion, pricing_config.extras) - Tests del motor ampliados (impermeabilización, extras, intensidad por tipo) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
'use server';
|
|
|
|
import { z } from 'zod';
|
|
import { and, eq } from 'drizzle-orm';
|
|
import { redirect } from 'next/navigation';
|
|
import { revalidatePath } from 'next/cache';
|
|
import { db } from '@/db';
|
|
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
|
|
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
|
|
import { getTenantBySlug } from '@/lib/funnel/public-queries';
|
|
import { getTenantPerfilById } from '@/db/tenant-queries';
|
|
import { procesarLead } from '@/lib/funnel/orchestrator';
|
|
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
|
import { iniciarLlamadaSaliente, construirVariablesLlamada } from '@/lib/voice/retell';
|
|
import { iniciarConversacionWhatsapp } from '@/lib/webhooks';
|
|
import { enviarEnlaceFormulario } from '@/lib/email/mailer';
|
|
import { env } from '@/lib/env';
|
|
|
|
const MAX_ZONAS = 6;
|
|
const MAX_FOTOS_ZONA = 6;
|
|
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
|
|
|
const crearLeadSchema = z.object({
|
|
nombre: z.string().trim().min(2, 'El nombre es obligatorio'),
|
|
email: z.string().trim().email('Introduce un email válido'),
|
|
telefono: z
|
|
.string()
|
|
.trim()
|
|
.regex(/^[+\d\s\-().]{7,20}$/, 'Introduce un teléfono válido'),
|
|
consentPrivacidad: z.boolean(),
|
|
consentContratacion: z.boolean(),
|
|
});
|
|
|
|
export type CrearLeadInput = z.input<typeof crearLeadSchema>;
|
|
export type CrearLeadResult =
|
|
| { ok: true; leadId: string }
|
|
| { ok: false; error: string };
|
|
|
|
export async function crearLead(slug: string, input: CrearLeadInput): Promise<CrearLeadResult> {
|
|
const parsed = crearLeadSchema.safeParse(input);
|
|
if (!parsed.success) {
|
|
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' };
|
|
}
|
|
const data = parsed.data;
|
|
|
|
// RF-LEG-01: los dos consentimientos son obligatorios para iniciar el funnel.
|
|
if (!data.consentPrivacidad || !data.consentContratacion) {
|
|
return { ok: false, error: 'Debes aceptar la política de privacidad y las condiciones.' };
|
|
}
|
|
|
|
// El lead se atribuye al reformista dueño del funnel (slug de la URL pública).
|
|
const tenant = await getTenantBySlug(slug);
|
|
if (!tenant) {
|
|
return { ok: false, error: 'No hemos podido identificar al reformista. Recarga la página.' };
|
|
}
|
|
|
|
const [lead] = await db
|
|
.insert(leads)
|
|
.values({
|
|
tenantId: tenant.id,
|
|
nombre: data.nombre,
|
|
email: data.email,
|
|
telefono: data.telefono,
|
|
consentPrivacidad: data.consentPrivacidad,
|
|
consentContratacion: data.consentContratacion,
|
|
pipelineStage: 'form_completado',
|
|
estado: 'nuevo',
|
|
})
|
|
.returning({ id: leads.id });
|
|
|
|
await db.insert(leadPipelineEventos).values({
|
|
leadId: lead.id,
|
|
stage: 'form_completado',
|
|
metadata: { origen: 'landing' },
|
|
});
|
|
|
|
return { ok: true, leadId: lead.id };
|
|
}
|
|
|
|
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
|
|
const CALIDADES = ['basica', 'media', 'premium'] as const;
|
|
|
|
async function fileToDataUri(file: File): Promise<string | null> {
|
|
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
|
|
if (!file.type.startsWith('image/')) return null;
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
|
}
|
|
|
|
const CALIDAD_RANK: Record<(typeof CALIDADES)[number], number> = { basica: 0, media: 1, premium: 2 };
|
|
|
|
type ZonaParseada = {
|
|
tipo: (typeof TIPOS)[number];
|
|
m2: number | null;
|
|
calidad: (typeof CALIDADES)[number];
|
|
notas: string | null;
|
|
fotos: string[]; // data URIs
|
|
};
|
|
|
|
// Lee las zonas del FormData (campos zona-<i>-tipo / -m2 / -calidad / -notas / -fotos).
|
|
async function parsearZonas(formData: FormData): Promise<ZonaParseada[]> {
|
|
const count = Math.min(Number(formData.get('zonasCount')) || 0, MAX_ZONAS);
|
|
const zonas: ZonaParseada[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const tipoRaw = String(formData.get(`zona-${i}-tipo`) ?? '');
|
|
const calidadRaw = String(formData.get(`zona-${i}-calidad`) ?? '');
|
|
const m2Raw = Number(formData.get(`zona-${i}-m2`));
|
|
const tipo = (TIPOS as readonly string[]).includes(tipoRaw)
|
|
? (tipoRaw as (typeof TIPOS)[number])
|
|
: 'otro';
|
|
const calidad = (CALIDADES as readonly string[]).includes(calidadRaw)
|
|
? (calidadRaw as (typeof CALIDADES)[number])
|
|
: 'media';
|
|
const m2 = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
|
|
const notas = String(formData.get(`zona-${i}-notas`) ?? '').trim() || null;
|
|
|
|
const archivos = formData
|
|
.getAll(`zona-${i}-fotos`)
|
|
.filter((f): f is File => f instanceof File)
|
|
.slice(0, MAX_FOTOS_ZONA);
|
|
const fotos: string[] = [];
|
|
for (const file of archivos) {
|
|
const uri = await fileToDataUri(file);
|
|
if (uri) fotos.push(uri);
|
|
}
|
|
zonas.push({ tipo, m2, calidad, notas, fotos });
|
|
}
|
|
return zonas;
|
|
}
|
|
|
|
// Paso 2 (canal formulario): el cliente describe la reforma zona por zona y sube fotos.
|
|
// Guardamos fotos (momento 'antes', etiquetadas por zona) y notas como data en lead_notas;
|
|
// agregamos los campos del lead para calcular el presupuesto orientativo al instante con el motor
|
|
// actual, y señalamos "perfil completo" al flujo externo para que genere los renders "después".
|
|
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
|
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
|
if (!lead) throw new Error('Solicitud no encontrada.');
|
|
const tenantId = lead.tenantId;
|
|
|
|
const provincia = String(formData.get('provincia') ?? '').trim() || null;
|
|
const urgenciaRaw = String(formData.get('urgencia') ?? '');
|
|
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
|
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
|
: null;
|
|
const targetEuros = Number(formData.get('presupuestoTarget'));
|
|
const presupuestoTarget =
|
|
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
|
const estructural = formData.get('estructural') === 'on';
|
|
const anteriorA2000 = formData.get('anteriorA2000') === 'on';
|
|
const cambioDistribucion = formData.get('cambioDistribucion') === 'on';
|
|
|
|
let zonas = await parsearZonas(formData);
|
|
if (zonas.length === 0) {
|
|
zonas = [{ tipo: 'otro', m2: null, calidad: 'media', notas: null, fotos: [] }];
|
|
}
|
|
|
|
// Inserta fotos (antes, por zona) y notas (por zona) en la estructura del lead.
|
|
const fotoRows: NewLeadFoto[] = [];
|
|
const notaRows: NewLeadNota[] = [];
|
|
let orden = 0;
|
|
for (const z of zonas) {
|
|
for (const url of z.fotos) {
|
|
fotoRows.push({ leadId, url, momento: 'antes', zona: z.tipo, orden: orden++ });
|
|
}
|
|
if (z.notas) notaRows.push({ leadId, texto: z.notas, zona: z.tipo, origen: 'funnel' });
|
|
}
|
|
if (fotoRows.length > 0) await db.insert(leadFotos).values(fotoRows);
|
|
if (notaRows.length > 0) await db.insert(leadNotas).values(notaRows);
|
|
|
|
// Agregado para el motor de presupuesto (multi-zona "de verdad" = F1.5): m² suma, tipo único
|
|
// o 'integral' si hay varias zonas, calidad la más alta, y tasteText con las notas concatenadas.
|
|
const tiposUnicos = Array.from(new Set(zonas.map((z) => z.tipo)));
|
|
const tipoReforma = tiposUnicos.length === 1 ? tiposUnicos[0] : 'integral';
|
|
const m2Total = zonas.reduce((s, z) => s + (z.m2 ?? 0), 0);
|
|
const m2Suelo = m2Total > 0 ? m2Total : null;
|
|
const calidadGlobal = zonas.reduce<(typeof CALIDADES)[number]>(
|
|
(best, z) => (CALIDAD_RANK[z.calidad] > CALIDAD_RANK[best] ? z.calidad : best),
|
|
'basica',
|
|
);
|
|
const tasteText =
|
|
zonas
|
|
.filter((z) => z.notas)
|
|
.map((z) => `${z.tipo}: ${z.notas}`)
|
|
.join('\n') || null;
|
|
|
|
await db
|
|
.update(leads)
|
|
.set({
|
|
tipoReforma,
|
|
calidadGlobal,
|
|
m2Suelo,
|
|
provincia,
|
|
urgencia,
|
|
presupuestoTarget,
|
|
estructural,
|
|
anteriorA2000,
|
|
cambioDistribucion,
|
|
tasteText,
|
|
pipelineStage: 'fotos_subidas',
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
|
|
|
|
await db.insert(leadPipelineEventos).values({
|
|
leadId,
|
|
stage: 'fotos_subidas',
|
|
metadata: { fotos: fotoRows.length, notas: notaRows.length, zonas: zonas.length },
|
|
});
|
|
|
|
// Presupuesto orientativo inmediato (motor actual). La rama de WhatsApp queda simulada.
|
|
await procesarLead(leadId);
|
|
|
|
// Señala al flujo externo que el perfil está listo para generar los renders "después".
|
|
await señalarPerfilCompleto(leadId);
|
|
|
|
revalidatePath('/panel');
|
|
redirect(`/solicitud/${leadId}/estado`);
|
|
}
|
|
|
|
// Canal llamada: el cliente pide que le llamen ahora o programa la llamada. "Ahora" dispara la
|
|
// llamada saliente de Retell; "programar" registra la fecha y la señala (el dialing en hora lo
|
|
// hace el flujo externo, la app no monta cron). Best-effort.
|
|
export async function pedirLlamada(
|
|
leadId: string,
|
|
cuando: 'ahora' | string,
|
|
): Promise<{ ok: boolean; programada?: string }> {
|
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
|
if (!lead) return { ok: false };
|
|
const tenant = await getTenantPerfilById(lead.tenantId);
|
|
|
|
if (cuando === 'ahora') {
|
|
const llamada = await iniciarLlamadaSaliente({
|
|
telefono: lead.telefono,
|
|
variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead),
|
|
});
|
|
await db.insert(leadPipelineEventos).values({
|
|
leadId,
|
|
stage: 'prellamada_enviada',
|
|
metadata: { via: 'llamada', cuando: 'ahora', real: Boolean(llamada), simulado: !llamada },
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
const fecha = new Date(cuando);
|
|
const programadaAt = Number.isNaN(fecha.getTime()) ? null : fecha.toISOString();
|
|
await db.insert(leadPipelineEventos).values({
|
|
leadId,
|
|
stage: 'prellamada_enviada',
|
|
metadata: { via: 'llamada', cuando: 'programada', programadaAt },
|
|
});
|
|
return { ok: true, programada: programadaAt ?? undefined };
|
|
}
|
|
|
|
// Canal llamada: envía al cliente un email con el enlace a su formulario para subir las imágenes.
|
|
export async function enviarEnlaceFormularioEmail(leadId: string): Promise<boolean> {
|
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
|
if (!lead) return false;
|
|
const tenant = await getTenantPerfilById(lead.tenantId);
|
|
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/formulario`;
|
|
return enviarEnlaceFormulario({
|
|
to: lead.email,
|
|
nombre: lead.nombre,
|
|
empresa: tenant.nombreEmpresa,
|
|
url,
|
|
});
|
|
}
|
|
|
|
// Canal WhatsApp: arranca la conversación con el lead a través del flujo externo (que manda el
|
|
// primer mensaje a su teléfono) y deja traza. El cliente confirma luego en la UI.
|
|
export async function iniciarWhatsapp(leadId: string): Promise<{ ok: boolean; telefono: string }> {
|
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
|
if (!lead) return { ok: false, telefono: '' };
|
|
const tenant = await getTenantPerfilById(lead.tenantId);
|
|
const ok = await iniciarConversacionWhatsapp({
|
|
leadId,
|
|
telefono: lead.telefono,
|
|
nombre: lead.nombre,
|
|
empresa: tenant.nombreEmpresa,
|
|
});
|
|
return { ok, telefono: lead.telefono };
|
|
}
|
|
|
|
// Canal WhatsApp: el cliente confirma que ha recibido el mensaje; seguimos por WhatsApp.
|
|
export async function confirmarWhatsapp(leadId: string): Promise<{ ok: boolean }> {
|
|
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, leadId)).limit(1);
|
|
if (!lead) return { ok: false };
|
|
await db.insert(leadPipelineEventos).values({
|
|
leadId,
|
|
stage: 'prellamada_enviada',
|
|
metadata: { via: 'whatsapp', confirmado: true },
|
|
});
|
|
return { ok: true };
|
|
}
|