Añade EPs HTTP para que el bot de WhatsApp pueble la BD
El bot (Luisa) es externo y no toca Postgres directamente. Cuatro endpoints autenticados con Bearer FUNNEL_API_KEY, validados con zod: - POST /api/leads/:id/conversacion → turno de chat (+ estado_wa/bot_step) - POST /api/leads/:id/perfil → update parcial del lead (extracción) - POST /api/leads/:id/calificacion → upsert de lead_calificacion - POST /api/leads/:id/intento → registro en intentos_contacto Helpers compartidos lib/api/funnel-auth.ts (autorizado + jsonResponse) y lib/api/bot-request.ts (validarBotRequest: auth + JSON + zod + lead existe). La ruta de ingesta se refactoriza para reutilizar funnel-auth (DRY). Schemas puros en lib/funnel/bot-schemas.ts con tests, y doc en api-docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal file
38
mvp/b2c/src/app/api/leads/[id]/calificacion/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { db } from '@/db';
|
||||
import { leadCalificacion } from '@/db/schema';
|
||||
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||
import { calificacionSchema } from '@/lib/funnel/bot-schemas';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Upsert de la calificación del lead (una por lead). El bot la recalcula a medida que avanza
|
||||
// la conversación; onConflict actualiza la fila existente.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const v = await validarBotRequest(req, params, calificacionSchema);
|
||||
if ('error' in v) return v.error;
|
||||
const { leadId, body } = v;
|
||||
|
||||
await db
|
||||
.insert(leadCalificacion)
|
||||
.values({
|
||||
leadId,
|
||||
score: body.score,
|
||||
nivel: body.nivel,
|
||||
criterios: body.criterios,
|
||||
notasAgente: body.notasAgente,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: leadCalificacion.leadId,
|
||||
set: {
|
||||
score: body.score,
|
||||
nivel: body.nivel,
|
||||
criterios: body.criterios,
|
||||
notasAgente: body.notasAgente,
|
||||
calificadoAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse({ ok: true }, 200);
|
||||
}
|
||||
38
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal file
38
mvp/b2c/src/app/api/leads/[id]/conversacion/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads, conversacionWhatsapp } from '@/db/schema';
|
||||
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||
import { conversacionSchema } from '@/lib/funnel/bot-schemas';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Añade un turno de la conversación de WhatsApp al historial del lead, y opcionalmente actualiza
|
||||
// el estado del mensaje (estado_wa) y el paso del bot (bot_step) en el lead.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const v = await validarBotRequest(req, params, conversacionSchema);
|
||||
if ('error' in v) return v.error;
|
||||
const { leadId, body } = v;
|
||||
|
||||
const [row] = await db
|
||||
.insert(conversacionWhatsapp)
|
||||
.values({
|
||||
leadId,
|
||||
rol: body.rol,
|
||||
mensaje: body.mensaje,
|
||||
mediaType: body.mediaType ?? null,
|
||||
mediaUrl: body.mediaUrl ?? null,
|
||||
transcripcionAudio: body.transcripcionAudio ?? null,
|
||||
})
|
||||
.returning({ id: conversacionWhatsapp.id });
|
||||
|
||||
if (body.estadoWa || body.botStep) {
|
||||
await db
|
||||
.update(leads)
|
||||
.set({ estadoWa: body.estadoWa, botStep: body.botStep, updatedAt: new Date() })
|
||||
.where(eq(leads.id, leadId));
|
||||
}
|
||||
|
||||
return jsonResponse({ ok: true, id: row.id }, 200);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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 { autorizado, jsonResponse as json } from '@/lib/api/funnel-auth';
|
||||
import { ingestaBodySchema } from '@/lib/funnel/ingesta-schema';
|
||||
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
||||
import { finalizarYEntregar } from '@/lib/funnel/finalizar';
|
||||
@@ -10,20 +10,6 @@ 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).
|
||||
|
||||
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal file
31
mvp/b2c/src/app/api/leads/[id]/intento/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { db } from '@/db';
|
||||
import { intentosContacto } from '@/db/schema';
|
||||
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||
import { intentoSchema } from '@/lib/funnel/bot-schemas';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Registra un intento de contacto (formulario/whatsapp/llamada) con su resultado.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const v = await validarBotRequest(req, params, intentoSchema);
|
||||
if ('error' in v) return v.error;
|
||||
const { leadId, body } = v;
|
||||
|
||||
const [row] = await db
|
||||
.insert(intentosContacto)
|
||||
.values({
|
||||
leadId,
|
||||
canal: body.canal,
|
||||
resultado: body.resultado,
|
||||
completado: body.completado ?? false,
|
||||
numeroIntento: body.numeroIntento,
|
||||
duracionSeg: body.duracionSeg,
|
||||
notas: body.notas,
|
||||
metadata: body.metadata,
|
||||
})
|
||||
.returning({ id: intentosContacto.id });
|
||||
|
||||
return jsonResponse({ ok: true, id: row.id }, 200);
|
||||
}
|
||||
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal file
28
mvp/b2c/src/app/api/leads/[id]/perfil/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads } from '@/db/schema';
|
||||
import { jsonResponse } from '@/lib/api/funnel-auth';
|
||||
import { validarBotRequest } from '@/lib/api/bot-request';
|
||||
import { perfilSchema } from '@/lib/funnel/bot-schemas';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Actualización parcial del lead con lo que el bot va extrayendo (espacio, m², estilo, urgencia,
|
||||
// presupuesto, viabilidad, estado de la conversación…). Solo escribe los campos enviados.
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const v = await validarBotRequest(req, params, perfilSchema);
|
||||
if ('error' in v) return v.error;
|
||||
const { leadId, body } = v;
|
||||
|
||||
await db
|
||||
.update(leads)
|
||||
.set({
|
||||
...body,
|
||||
fotosSolicitadasAt: body.fotosSolicitadasAt ? new Date(body.fotosSolicitadasAt) : undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(leads.id, leadId));
|
||||
|
||||
return jsonResponse({ ok: true, actualizado: Object.keys(body) }, 200);
|
||||
}
|
||||
34
mvp/b2c/src/lib/api/bot-request.ts
Normal file
34
mvp/b2c/src/lib/api/bot-request.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { leads } from '@/db/schema';
|
||||
import { autorizado, jsonResponse } from '@/lib/api/funnel-auth';
|
||||
|
||||
// Valida una petición de los EPs del bot: auth Bearer + JSON + zod + que el lead exista.
|
||||
// Devuelve { error } con la Response lista, o { leadId, body } ya validado.
|
||||
export async function validarBotRequest<S extends z.ZodTypeAny>(
|
||||
req: Request,
|
||||
params: Promise<{ id: string }>,
|
||||
schema: S,
|
||||
): Promise<{ error: Response } | { leadId: string; body: z.infer<S> }> {
|
||||
if (!autorizado(req)) return { error: jsonResponse({ ok: false, error: 'No autorizado.' }, 401) };
|
||||
const { id } = await params;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
return { error: jsonResponse({ ok: false, error: 'JSON inválido.' }, 422) };
|
||||
}
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: jsonResponse({ ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' }, 422),
|
||||
};
|
||||
}
|
||||
|
||||
const [lead] = await db.select({ id: leads.id }).from(leads).where(eq(leads.id, id)).limit(1);
|
||||
if (!lead) return { error: jsonResponse({ ok: false, error: 'Lead no encontrado.' }, 404) };
|
||||
|
||||
return { leadId: id, body: parsed.data };
|
||||
}
|
||||
17
mvp/b2c/src/lib/api/funnel-auth.ts
Normal file
17
mvp/b2c/src/lib/api/funnel-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
// Auth compartida de los EPs públicos del funnel/bot: header Authorization: Bearer <FUNNEL_API_KEY>.
|
||||
// Sin clave configurada o sin coincidencia → no autorizado.
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
69
mvp/b2c/src/lib/funnel/bot-schemas.ts
Normal file
69
mvp/b2c/src/lib/funnel/bot-schemas.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { z } from 'zod';
|
||||
import { ZONAS } from '@/lib/funnel/ingesta-schema';
|
||||
|
||||
// Schemas de los EPs que el bot de WhatsApp usa para poblar la BD por API. Puros (sin DB) para
|
||||
// poder testearlos. Los enums son espejo de los de src/db/schema.ts.
|
||||
const ESTADO_WA = ['sin_enviar', 'enviado', 'entregado', 'leido', 'fallido'] as const;
|
||||
const CANAL_ORIGEN = ['formulario_web', 'whatsapp', 'llamada', 'referido', 'anuncio'] as const;
|
||||
const CALIDAD = ['basica', 'media', 'premium'] as const;
|
||||
const URGENCIA = ['alta', 'media', 'baja'] as const;
|
||||
const NIVEL = ['A', 'B', 'C', 'D'] as const;
|
||||
const CANAL_CONTACTO = ['formulario', 'whatsapp', 'llamada'] as const;
|
||||
const RESULTADO = ['exitoso', 'no_contesta', 'ocupado', 'rechaza', 'error_tecnico'] as const;
|
||||
|
||||
// Un turno de la conversación de WhatsApp (+ estado opcional del mensaje/conversación).
|
||||
export const conversacionSchema = z.object({
|
||||
rol: z.enum(['user', 'assistant', 'system']),
|
||||
mensaje: z.string().trim().min(1),
|
||||
mediaType: z.string().optional(),
|
||||
mediaUrl: z.string().optional(),
|
||||
transcripcionAudio: z.string().optional(),
|
||||
estadoWa: z.enum(ESTADO_WA).optional(),
|
||||
botStep: z.string().optional(),
|
||||
});
|
||||
|
||||
// Actualización parcial de la extracción/estado del lead (lo que Luisa va sacando).
|
||||
export const perfilSchema = z
|
||||
.object({
|
||||
botStep: z.string().optional(),
|
||||
estadoWa: z.enum(ESTADO_WA).optional(),
|
||||
canalOrigen: z.enum(CANAL_ORIGEN).optional(),
|
||||
viable: z.boolean().optional(),
|
||||
espacio: z.string().optional(),
|
||||
rangoM2: z.string().optional(),
|
||||
estilo: z.string().optional(),
|
||||
presupuestoDeclarado: z.string().optional(),
|
||||
fotosSolicitadasAt: z.string().datetime().optional(),
|
||||
// Normalizados (los que alimentan el motor de presupuesto):
|
||||
tipoReforma: z.enum(ZONAS).optional(),
|
||||
m2Suelo: z.number().positive().optional(),
|
||||
calidadGlobal: z.enum(CALIDAD).optional(),
|
||||
urgencia: z.enum(URGENCIA).optional(),
|
||||
presupuestoTarget: z.number().int().min(0).optional(), // céntimos
|
||||
tasteText: z.string().optional(),
|
||||
estructural: z.boolean().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'Cuerpo vacío: aporta al menos un campo.' });
|
||||
|
||||
// Calificación del lead (upsert; 1 por lead).
|
||||
export const calificacionSchema = z
|
||||
.object({
|
||||
score: z.number().int().min(0).max(100).optional(),
|
||||
nivel: z.enum(NIVEL).optional(),
|
||||
criterios: z.unknown().optional(),
|
||||
notasAgente: z.string().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'Cuerpo vacío: aporta al menos un campo.' });
|
||||
|
||||
// Registro de un intento de contacto.
|
||||
export const intentoSchema = z.object({
|
||||
canal: z.enum(CANAL_CONTACTO),
|
||||
resultado: z.enum(RESULTADO).optional(),
|
||||
completado: z.boolean().optional(),
|
||||
numeroIntento: z.number().int().min(1),
|
||||
duracionSeg: z.number().int().min(0).optional(),
|
||||
notas: z.string().optional(),
|
||||
metadata: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export type PerfilBody = z.infer<typeof perfilSchema>;
|
||||
Reference in New Issue
Block a user