Files
reformix-hackaton/mvp/b2c/src/app/solicitud/actions.ts
Carlos Narro 508fc43f1f Enlace del email = subir solo fotos (sin re-preguntar ni re-llamar)
Arregla 2 problemas del flujo de subir fotos desde el email:
- El enlace iba a /formulario (form completo) y al enviarlo re-ejecutaba
  procesarLead, que VOLVÍA a llamar. Ahora el email apunta a /solicitud/[id]/fotos,
  una página ligera (SubirFotos): solo sube fotos (+ nota opcional) al lead de la
  URL, re-señala perfilCompleto y NO llama.
- Guarda en procesarLead: si el lead ya tiene llamada_completada, no se vuelve a
  llamar (ni se pisa la transcripción real del webhook).
Copy de la página en COPY-GUIDE §3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:33:15 +02:00

360 lines
14 KiB
TypeScript

'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<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);
// 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<void> {
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<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 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 };
}