Añade el EP único de ingesta async del lead

POST /api/leads/:id/ingesta (Bearer FUNNEL_API_KEY): acepta items foto/texto
etiquetados por zona y momento, más flags perfilCompleto y finalizar.
- ingesta-schema.ts: zod del cuerpo (union discriminada foto|texto), exportado
  para test; rechaza llamadas vacías.
- route.ts: auth 401, valida lead (404), inserta fotos (orden continúa el máx)
  y notas, traza fotos_subidas; perfilCompleto→señalarPerfilCompleto,
  finalizar→finalizarYEntregar.
- 10 tests del schema. Verificado por HTTP: 401/200/422/404 y finalizar genera
  el pdf_url y avanza el lead a whatsapp_entregado.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-03 19:09:16 +02:00
parent 195ecf6cc3
commit ae8984fe13
3 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
import { env } from '@/lib/env';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
function autorizado(req: Request): boolean {
if (!env.FUNNEL_API_KEY) return false;
const auth = req.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
return token.length > 0 && token === env.FUNNEL_API_KEY;
}
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
});
}
// EP único de ingesta async del perfil del lead. Acepta imágenes, imagen+texto o solo texto,
// etiquetado por zona y momento, y dos flags: perfilCompleto (señala al flujo externo que genere
// renders/agente) y finalizar (construye el PDF y lo entrega por email + señal WhatsApp).
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!autorizado(req)) return json({ ok: false, error: 'No autorizado.' }, 401);
const { id } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return json({ ok: false, error: 'JSON inválido.' }, 422);
}
const parsed = ingestaBodySchema.safeParse(raw);
if (!parsed.success) {
return json({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422);
}
const body = parsed.data;
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
if (!lead) return json({ ok: false, error: 'Lead no encontrado.' }, 404);
const fotosItems = body.items.filter((i) => i.tipo === 'foto');
const notasItems = body.items.filter((i) => i.tipo === 'texto');
if (fotosItems.length > 0) {
const [ultimo] = await db
.select({ orden: leadFotos.orden })
.from(leadFotos)
.where(eq(leadFotos.leadId, id))
.orderBy(desc(leadFotos.orden))
.limit(1);
let siguiente = (ultimo?.orden ?? -1) + 1;
const filas: NewLeadFoto[] = fotosItems.map((f) => ({
leadId: id,
url: f.imagen,
momento: f.momento,
zona: f.zona ?? null,
orden: f.orden ?? siguiente++,
}));
await db.insert(leadFotos).values(filas);
}
if (notasItems.length > 0) {
const filas: NewLeadNota[] = notasItems.map((n) => ({
leadId: id,
texto: n.texto,
zona: n.zona ?? null,
origen: 'ep',
}));
await db.insert(leadNotas).values(filas);
}
if (fotosItems.length > 0 || notasItems.length > 0) {
await db.insert(leadPipelineEventos).values({
leadId: id,
stage: 'fotos_subidas',
metadata: { origen: 'ep', fotos: fotosItems.length, notas: notasItems.length },
});
}
const perfilSenalado = body.perfilCompleto ? await señalarPerfilCompleto(id) : false;
const finalizado = body.finalizar ? await finalizarYEntregar(id) : null;
return json(
{
ok: true,
fotos: fotosItems.length,
notas: notasItems.length,
perfilSenalado,
finalizado,
},
200,
);
}