'use server'; import { z } from 'zod'; import { and, desc, 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 { resolveTheme } from '@/lib/funnel/themes'; 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; export type CrearLeadResult = | { ok: true; leadId: string } | { ok: false; error: string }; export async function crearLead(slug: string, input: CrearLeadInput): Promise { 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 { 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--tipo / -m2 / -calidad / -notas / -fotos). async function parsearZonas(formData: FormData): Promise { 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 { 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); // Mandamos el email con el enlace para subir fotos: el agente se lo recuerda en la llamada // ("te enviamos un email con un enlace"). Best-effort, no bloquea la llamada. await enviarEnlaceFormularioEmail(leadId); if (cuando === 'ahora') { const llamada = await iniciarLlamadaSaliente({ telefono: lead.telefono, variables: construirVariablesLlamada({ nombreEmpresa: tenant.nombreEmpresa }, lead), leadId, }); 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 }; } // Página ligera del enlace del email: el cliente solo sube fotos del espacio. NO ejecuta // procesarLead, así que NO vuelve a llamar (la llamada, si tocaba, ya se hizo). Solo guarda las // fotos en ESTE lead (id de la URL) y re-señala el perfil para regenerar el render con ellas. export async function subirFotos(leadId: string, formData: FormData): Promise { const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); if (!lead) throw new Error('Solicitud no encontrada.'); const archivos = formData .getAll('fotos') .filter((f): f is File => f instanceof File) .slice(0, MAX_FOTOS_ZONA); const dataUris: string[] = []; for (const file of archivos) { const uri = await fileToDataUri(file); if (uri) dataUris.push(uri); } const nota = String(formData.get('nota') ?? '').trim() || null; if (dataUris.length > 0) { const [ultimo] = await db .select({ orden: leadFotos.orden }) .from(leadFotos) .where(eq(leadFotos.leadId, leadId)) .orderBy(desc(leadFotos.orden)) .limit(1); let orden = (ultimo?.orden ?? -1) + 1; const filas: NewLeadFoto[] = dataUris.map((url) => ({ leadId, url, momento: 'antes', zona: lead.tipoReforma ?? null, orden: orden++, })); await db.insert(leadFotos).values(filas); } if (nota) { await db .insert(leadNotas) .values({ leadId, texto: nota, zona: lead.tipoReforma ?? null, origen: 'funnel' }); } await db.insert(leadPipelineEventos).values({ leadId, stage: 'fotos_subidas', metadata: { origen: 'email', fotos: dataUris.length, notas: nota ? 1 : 0 }, }); // Re-señala el perfil para que el flujo externo regenere el render con las fotos nuevas. await señalarPerfilCompleto(leadId); revalidatePath('/panel'); redirect(`/solicitud/${leadId}/estado`); } // Canal llamada: envía al cliente un email con el enlace para subir las imágenes (página ligera). export async function enviarEnlaceFormularioEmail(leadId: string): Promise { 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 theme = resolveTheme(tenant.themePreset, tenant.themeColor); const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/fotos`; return enviarEnlaceFormulario({ to: lead.email, nombre: lead.nombre, empresa: tenant.nombreEmpresa, url, brand: { primary: theme.primary, primaryDark: theme.primaryDark, contrast: theme.contrast, logoUrl: tenant.logoUrl, }, }); } // 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 }; }