'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, leadPipelineEventos } from '@/db/schema'; import { getTenantBySlug } from '@/lib/funnel/public-queries'; import { procesarLead } from '@/lib/funnel/orchestrator'; const MAX_FOTOS = 4; 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')}`; } // Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma. // Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos // el orquestador que simula la llamada/render y calcula el presupuesto real. 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 tipoRaw = String(formData.get('tipoReforma') ?? ''); const calidadRaw = String(formData.get('calidad') ?? ''); const m2Raw = Number(formData.get('m2')); const provincia = String(formData.get('provincia') ?? '').trim() || null; const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw) ? (tipoRaw as (typeof TIPOS)[number]) : 'otro'; const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw) ? (calidadRaw as (typeof CALIDADES)[number]) : 'media'; const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : 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 tasteText = String(formData.get('tasteText') ?? '').trim() || null; const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File); const dataUris: string[] = []; for (const file of archivos.slice(0, MAX_FOTOS)) { const uri = await fileToDataUri(file); if (uri) dataUris.push(uri); } if (dataUris.length > 0) { await db.insert(leadFotos).values( dataUris.map((url, orden) => ({ leadId, url, orden })) ); } await db .update(leads) .set({ tipoReforma, calidadGlobal, m2Suelo, provincia, urgencia, presupuestoTarget, estructural, 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: dataUris.length }, }); // Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp). await procesarLead(leadId); revalidatePath('/panel'); redirect(`/solicitud/${leadId}/estado`); }