From ae8984fe13e7751497f8792f48307228f097730f Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 3 Jun 2026 19:09:16 +0200 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20el=20EP=20=C3=BAnico=20de=20ingest?= =?UTF-8?q?a=20async=20del=20lead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/app/api/leads/[id]/ingesta/route.ts | 102 ++++++++++++++++++ mvp/b2c/src/lib/funnel/ingesta-schema.ts | 34 ++++++ mvp/b2c/tests/api/ingesta-schema.test.ts | 74 +++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts create mode 100644 mvp/b2c/src/lib/funnel/ingesta-schema.ts create mode 100644 mvp/b2c/tests/api/ingesta-schema.test.ts diff --git a/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts b/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts new file mode 100644 index 0000000..b195c98 --- /dev/null +++ b/mvp/b2c/src/app/api/leads/[id]/ingesta/route.ts @@ -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, + ); +} diff --git a/mvp/b2c/src/lib/funnel/ingesta-schema.ts b/mvp/b2c/src/lib/funnel/ingesta-schema.ts new file mode 100644 index 0000000..2cbb6a5 --- /dev/null +++ b/mvp/b2c/src/lib/funnel/ingesta-schema.ts @@ -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; +export type IngestaItem = IngestaBody['items'][number]; diff --git a/mvp/b2c/tests/api/ingesta-schema.test.ts b/mvp/b2c/tests/api/ingesta-schema.test.ts new file mode 100644 index 0000000..5c66f38 --- /dev/null +++ b/mvp/b2c/tests/api/ingesta-schema.test.ts @@ -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, + ); + }); +});