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:
102
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal file
102
mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user