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,
);
}

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
// Espejo del enum tipo_reforma (src/db/schema.ts). Se mantiene aquí como tupla literal para que
// el schema sea puro (sin importar drizzle) y los tests lo validen sin tocar la DB.
export const ZONAS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const itemFoto = z.object({
tipo: z.literal('foto'),
zona: z.enum(ZONAS).optional(),
momento: z.enum(['antes', 'despues']).default('antes'),
imagen: z.string().min(1), // data URI o URL http(s)
orden: z.number().int().min(0).optional(),
});
const itemTexto = z.object({
tipo: z.literal('texto'),
zona: z.enum(ZONAS).optional(),
texto: z.string().trim().min(1),
});
// Cuerpo del EP de ingesta: una lista de items (foto o texto) y/o flags. Una llamada vacía
// (sin items ni flag) se rechaza.
export const ingestaBodySchema = z
.object({
items: z.array(z.discriminatedUnion('tipo', [itemFoto, itemTexto])).default([]),
perfilCompleto: z.boolean().optional(),
finalizar: z.boolean().optional(),
})
.refine((b) => b.items.length > 0 || b.perfilCompleto || b.finalizar, {
message: 'Llamada vacía: aporta items o un flag (perfilCompleto/finalizar).',
});
export type IngestaBody = z.infer<typeof ingestaBodySchema>;
export type IngestaItem = IngestaBody['items'][number];

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
describe('ingestaBodySchema', () => {
it('acepta una foto con zona y momento por defecto "antes"', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' }],
});
expect(r.success).toBe(true);
if (r.success) {
const foto = r.data.items[0];
expect(foto.tipo).toBe('foto');
if (foto.tipo === 'foto') expect(foto.momento).toBe('antes');
}
});
it('acepta momento "despues" explícito', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'cocina', momento: 'despues', imagen: 'https://x/y.jpg' }],
});
expect(r.success).toBe(true);
});
it('acepta una nota de texto y zona opcional', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: 'suelo premium' }] });
expect(r.success).toBe(true);
});
it('acepta items mixtos foto + texto', () => {
const r = ingestaBodySchema.safeParse({
items: [
{ tipo: 'foto', zona: 'bano', imagen: 'data:image/png;base64,AAA' },
{ tipo: 'texto', zona: 'bano', texto: 'suelo premium' },
],
});
expect(r.success).toBe(true);
});
it('acepta una llamada solo con flag perfilCompleto (sin items)', () => {
const r = ingestaBodySchema.safeParse({ perfilCompleto: true });
expect(r.success).toBe(true);
if (r.success) expect(r.data.items).toEqual([]);
});
it('acepta una llamada solo con flag finalizar', () => {
expect(ingestaBodySchema.safeParse({ finalizar: true }).success).toBe(true);
});
it('rechaza una llamada totalmente vacía', () => {
expect(ingestaBodySchema.safeParse({}).success).toBe(false);
expect(ingestaBodySchema.safeParse({ items: [] }).success).toBe(false);
});
it('rechaza un tipo de item inválido', () => {
const r = ingestaBodySchema.safeParse({ items: [{ tipo: 'video', imagen: 'x' }] });
expect(r.success).toBe(false);
});
it('rechaza una zona fuera del enum', () => {
const r = ingestaBodySchema.safeParse({
items: [{ tipo: 'foto', zona: 'jardin', imagen: 'data:...' }],
});
expect(r.success).toBe(false);
});
it('rechaza foto sin imagen y texto vacío', () => {
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'foto', imagen: '' }] }).success).toBe(
false,
);
expect(ingestaBodySchema.safeParse({ items: [{ tipo: 'texto', texto: ' ' }] }).success).toBe(
false,
);
});
});