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>
This commit is contained in:
Carlos Narro
2026-06-07 19:33:15 +02:00
parent 98f02eb02e
commit 508fc43f1f
5 changed files with 222 additions and 25 deletions

View File

@@ -1,7 +1,7 @@
'use server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { and, desc, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
@@ -257,13 +257,66 @@ export async function pedirLlamada(
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.
// 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}/formulario`;
const url = `${env.APP_URL ?? ''}/solicitud/${leadId}/fotos`;
return enviarEnlaceFormulario({
to: lead.email,
nombre: lead.nombre,