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,
|
||||||
|
);
|
||||||
|
}
|
||||||
34
mvp/b2c/src/lib/funnel/ingesta-schema.ts
Normal file
34
mvp/b2c/src/lib/funnel/ingesta-schema.ts
Normal 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];
|
||||||
74
mvp/b2c/tests/api/ingesta-schema.test.ts
Normal file
74
mvp/b2c/tests/api/ingesta-schema.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user