almeira-web/dripzone-app/src/app/api/whatsapp-feed/route.ts

596 lines
23 KiB
TypeScript

import { NextResponse } from 'next/server';
import { dataService } from '@/lib/data-service';
import { parseSupplierMessage, detectCategory } from '@/lib/gemini-parser';
import { performMarketIntelligenceSearch } from '@/lib/market-intelligence';
import { getProductSocialProof } from '@/lib/social-proof';
import { generateAttractiveProductCopy } from '@/lib/attractive-copy';
import { sanitizePublicationTitle, sanitizePublicationText } from '@/lib/sanitizer';
import {
getSupplierPostsFromAppwrite,
saveSupplierPostToAppwrite,
updateSupplierPostStatusInAppwrite,
saveProductToAppwrite,
getProductsFromAppwrite,
} from '@/lib/server-appwrite';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
const EVOLUTION_API_KEY = process.env.EVOLUTION_FEED_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_FEED_INSTANCE_NAME || 'AutomataAlmeira';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const fetchLive = searchParams.get('fetchLive') !== 'false';
const limit = parseInt(searchParams.get('limit') || '3000', 10);
const maxPages = parseInt(searchParams.get('pages') || '30', 10);
const filterJid = searchParams.get('groupJid');
const statusFilter = (searchParams.get('status') || 'pending').toLowerCase();
// 1. Obtener posts y productos existentes en Appwrite para control estricto de IDs de mensajes de WhatsApp
const [savedPosts, existingProducts] = await Promise.all([
getSupplierPostsFromAppwrite(),
getProductsFromAppwrite(),
]);
// Índices de WhatsApp message IDs ya procesados
const publishedMsgIds = new Set<string>();
const discardedMsgIds = new Set<string>();
const appwritePostMap = new Map<string, SupplierPost>();
savedPosts.forEach((p) => {
appwritePostMap.set(p.id, p);
const msgId = p.whatsappMessageId || p.id.replace('post-', '');
if (msgId) {
if (p.status === 'published' || p.status === 'published_store' || p.status === 'published_facebook') {
publishedMsgIds.add(msgId);
publishedMsgIds.add(`post-${msgId}`);
publishedMsgIds.add(p.id);
} else if (p.status === 'discarded' || p.status === 'ignored') {
discardedMsgIds.add(msgId);
discardedMsgIds.add(`post-${msgId}`);
discardedMsgIds.add(p.id);
}
}
});
existingProducts.forEach((prod) => {
if (prod.whatsappMessageId) {
publishedMsgIds.add(prod.whatsappMessageId);
publishedMsgIds.add(`post-${prod.whatsappMessageId}`);
}
});
// 2. Obtener grupos locales y descubrir grupos en vivo desde Evolution API
const localGroups: MonitoredWhatsAppGroup[] = dataService.getMonitoredGroups();
const groupsMap = new Map<string, string>();
localGroups.forEach((g) => groupsMap.set(g.jid, g.name));
let liveGroups: any[] = [];
try {
const chatsRes = await fetch(`${EVOLUTION_API_URL}/chat/findChats/${EVOLUTION_INSTANCE_NAME}`, {
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const allChats = await chatsRes.json();
if (Array.isArray(allChats)) {
liveGroups = allChats.filter(
(c: any) => (c.remoteJid && c.remoteJid.includes('@g.us')) || (c.id && c.id.includes('@g.us'))
);
liveGroups.forEach((g: any) => {
const jid = g.remoteJid || g.id || g.jid;
const name = g.name || g.pushName || g.subject || 'Grupo WhatsApp';
if (jid) groupsMap.set(jid, name);
});
}
} catch (e) {
console.warn('Error resolviendo chats de WhatsApp:', e);
}
// 3. Determinar JIDs objetivo a consultar (excluyendo grupos de los que salimos o inactivos)
const leftJids = new Set(
localGroups.filter((g) => g.hasLeft || g.isStillMember === false || !g.isMonitored).map((g) => g.jid)
);
let targetJids: string[] = [];
if (filterJid && filterJid !== 'all') {
targetJids = [filterJid];
} else {
const monitored = localGroups.filter((g) => g.isMonitored && !g.hasLeft && g.isStillMember !== false).map((g) => g.jid);
if (monitored.length > 0) {
targetJids = monitored;
} else if (liveGroups.length > 0) {
targetJids = liveGroups
.map((g: any) => g.remoteJid || g.id)
.filter((jid: string) => !leftJids.has(jid));
} else {
targetJids = Array.from(groupsMap.keys()).filter((jid: string) => !leftJids.has(jid));
}
}
if (!fetchLive || targetJids.length === 0) {
const filteredSaved = savedPosts
.filter((p) => {
// Si el mensaje es de un grupo del que salimos y estamos en pendientes, no mostrarlo
if (p.channelJid && leftJids.has(p.channelJid) && statusFilter === 'pending') return false;
if (statusFilter === 'pending') return p.status === 'pending_review';
if (statusFilter === 'published') return p.status === 'published' || p.status === 'published_store';
if (statusFilter === 'discarded') return p.status === 'discarded';
return true;
})
.map((p) => {
// Si tiene thumbnail de baja resolución pero cuenta con ID de WhatsApp, suministrar la URL en Alta Resolución Full HD
if (p.whatsappMessageId && Array.isArray(p.rawImages) && (p.rawImages.length === 0 || p.rawImages[0].startsWith('data:image'))) {
return {
...p,
rawImages: [`/api/whatsapp-media?id=${encodeURIComponent(p.whatsappMessageId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`],
};
}
return p;
});
return NextResponse.json({
success: true,
isLive: false,
totalFound: filteredSaved.length,
counts: {
pending: savedPosts.filter((p) => p.status === 'pending_review').length,
published: publishedMsgIds.size,
discarded: discardedMsgIds.size,
all: savedPosts.length,
},
posts: filteredSaved,
groups: Array.from(groupsMap.entries()).map(([jid, name]) => ({ jid, name })),
});
}
// 4. Consultar mensajes de los grupos objetivo recorriendo todas las páginas en paralelo
const groupFetchPromises = targetJids.map(async (jid) => {
const allGroupRecords: any[] = [];
const pagesToFetch = Array.from({ length: maxPages }, (_, i) => i + 1);
// Lotes concurrentes de 10 páginas para máxima velocidad y profundidad
const BATCH_SIZE = 10;
for (let i = 0; i < pagesToFetch.length; i += BATCH_SIZE) {
const batch = pagesToFetch.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.allSettled(
batch.map(async (page) => {
const msgRes = await fetch(
`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=${page}&limit=50`,
{
method: 'POST',
headers: {
apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ where: { key: { remoteJid: jid } } }),
}
);
const msgData = await msgRes.json();
return msgData.messages?.records || msgData.records || (Array.isArray(msgData) ? msgData : []);
})
);
let hitEnd = false;
for (const res of batchResults) {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
allGroupRecords.push(...res.value);
if (res.value.length === 0) {
hitEnd = true;
}
}
}
if (hitEnd) break;
}
return allGroupRecords;
});
const results = await Promise.allSettled(groupFetchPromises);
const allRecords: any[] = [];
for (const res of results) {
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
allRecords.push(...res.value);
}
}
// Ordenar cronológicamente (más recientes primero)
allRecords.sort((a, b) => (b.messageTimestamp || 0) - (a.messageTimestamp || 0));
// 5. Procesar mensajes con control estricto de identificador de WhatsApp
const processedPosts: SupplierPost[] = [];
const seenMessageIds = new Set<string>();
for (const record of allRecords) {
const remoteJid = record.key?.remoteJid || '';
const rawMsgId = (record.key?.id || '').trim();
const cleanMsgId = rawMsgId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 36);
const postId = `post-${cleanMsgId || Date.now()}`;
if (seenMessageIds.has(postId) || (rawMsgId && seenMessageIds.has(rawMsgId))) {
continue;
}
seenMessageIds.add(postId);
if (rawMsgId) seenMessageIds.add(rawMsgId);
const text =
record.message?.conversation ||
record.message?.extendedTextMessage?.text ||
record.message?.imageMessage?.caption ||
record.message?.videoMessage?.caption ||
'';
const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista';
const hasImage = Boolean(record.message?.imageMessage);
if (!text && !hasImage) continue;
const existingInAppwrite =
appwritePostMap.get(postId) ||
savedPosts.find(
(p) =>
p.id === postId ||
p.whatsappMessageId === rawMsgId ||
p.whatsappMessageId === cleanMsgId ||
(p.rawMessage === text && (p as any).channelJid === remoteJid)
);
const isPublished =
existingInAppwrite?.status === 'published' ||
existingInAppwrite?.status === 'published_store' ||
existingInAppwrite?.status === 'published_facebook' ||
publishedMsgIds.has(rawMsgId) ||
publishedMsgIds.has(cleanMsgId) ||
publishedMsgIds.has(postId);
const isDiscarded =
existingInAppwrite?.status === 'discarded' ||
existingInAppwrite?.status === 'ignored' ||
discardedMsgIds.has(rawMsgId) ||
discardedMsgIds.has(cleanMsgId) ||
discardedMsgIds.has(postId);
const effectiveStatus: SupplierPost['status'] = isPublished
? 'published'
: isDiscarded
? 'discarded'
: 'pending_review';
if (statusFilter === 'pending') {
if (isPublished || isDiscarded) continue;
} else if (statusFilter === 'published') {
if (!isPublished) continue;
} else if (statusFilter === 'discarded') {
if (!isDiscarded) continue;
}
let imageUrls: string[] = [];
const imgMsg = record.message?.imageMessage;
if (imgMsg) {
if (rawMsgId) {
// URL de streaming en Alta Resolución (Full HD) desde Evolution API
imageUrls.push(
`/api/whatsapp-media?id=${encodeURIComponent(rawMsgId)}&remoteJid=${encodeURIComponent(remoteJid)}`
);
} else if (imgMsg.jpegThumbnail) {
imageUrls.push(`data:image/jpeg;base64,${imgMsg.jpegThumbnail}`);
} else if (imgMsg.url) {
imageUrls.push(imgMsg.url);
}
}
if (imageUrls.length === 0) {
imageUrls.push(
'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'
);
}
let extracted = existingInAppwrite?.extractedProduct;
if (!extracted) {
const cleanInputText = text || '';
extracted = await parseSupplierMessage(cleanInputText, undefined, [groupName]);
} else if (extracted) {
if (extracted.name) {
extracted.name = sanitizePublicationTitle(extracted.name, [groupName], extracted.brand);
}
if (!extracted.category || extracted.category === 'Calzado / Sneakers' || extracted.category === 'Sneakers / Colección') {
extracted.category = detectCategory(`${extracted.name || ''} ${text}`).category;
}
}
const timestamp = record.messageTimestamp
? new Date(record.messageTimestamp * 1000).toLocaleString('es-CO', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: 'short',
})
: 'Reciente';
const post: SupplierPost = {
id: postId,
whatsappMessageId: rawMsgId || cleanMsgId,
channelName: groupName,
channelJid: remoteJid,
rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`,
rawImages: imageUrls,
extractedProduct: extracted,
status: effectiveStatus,
receivedAt: timestamp,
supplierName: groupName,
supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573167455877',
isRead: Boolean(record.status === 'READ' || record.key?.fromMe),
};
processedPosts.push(post);
saveSupplierPostToAppwrite(post).catch(() => {});
if (processedPosts.length >= limit) break;
}
return NextResponse.json({
success: true,
isLive: true,
statusFilter,
totalFound: processedPosts.length,
counts: {
pending: processedPosts.filter((p) => p.status === 'pending_review').length,
published: publishedMsgIds.size,
discarded: discardedMsgIds.size,
all: processedPosts.length + publishedMsgIds.size + discardedMsgIds.size,
},
posts: processedPosts,
groups: Array.from(groupsMap.entries()).map(([jid, name]) => ({ jid, name })),
});
} catch (error: any) {
console.error('Error en /api/whatsapp-feed:', error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const {
action,
post,
posts,
customRetailPrice,
customName,
customBrand,
customColorName,
customSizes,
customCategory,
marketIntelligence,
} = body;
if (action === 'publish' && post) {
const retailPrice =
customRetailPrice ||
post.extractedProduct?.suggestedRetailPrice ||
(post.extractedProduct?.supplierPrice ? post.extractedProduct.supplierPrice + 35000 : 125000);
const rawProdName = customName || post.extractedProduct?.name || 'Artículo Urbano';
const prodBrand = customBrand || post.extractedProduct?.brand || 'Urbano / Variado';
const forbiddenContext = [post.channelName, post.supplierName];
const prodName = sanitizePublicationTitle(rawProdName, forbiddenContext, prodBrand);
const prodCategory = customCategory || post.extractedProduct?.category || 'Zapatillas';
const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
const prodDescription = generateAttractiveProductCopy({
name: prodName,
brand: prodBrand,
category: prodCategory,
retailPrice,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
forbiddenTerms: forbiddenContext,
});
const whatsappMsgId = post.whatsappMessageId || post.id.replace('post-', '');
const newProduct = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
colorName: customColorName || post.extractedProduct?.colorName,
brand: prodBrand,
supplierPrice: post.extractedProduct?.supplierPrice || 75000,
retailPrice: retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
marketPriceCOP: post.extractedProduct?.marketPriceCOP || marketIntelligence?.marketPriceCOP,
marketPriceSource: post.extractedProduct?.marketPriceSource || marketIntelligence?.marketPriceSource,
marketPriceSourceUrl: post.extractedProduct?.marketPriceSourceUrl || marketIntelligence?.marketPriceSourceUrl,
marketComparisonSummary: post.extractedProduct?.marketComparisonSummary || marketIntelligence?.marketComparisonSummary,
sizes: customSizes || post.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(post.rawImages) && post.rawImages.length > 0
? post.rawImages.map((img: string) => {
if (img.startsWith('data:image') && whatsappMsgId) {
return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(post.channelJid || '')}`;
}
return img;
})
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: prodDescription,
category: prodCategory,
inStock: true,
isActive: true,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount,
originBadge: proof.badge,
supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista',
supplierPhone: post.supplierPhone || '573167455877',
whatsappMessageId: whatsappMsgId,
};
const added = dataService.addProduct(newProduct as any);
await saveProductToAppwrite(added);
await updateSupplierPostStatusInAppwrite(post.id, 'published');
dataService.updateSupplierPostStatus(post.id, 'published');
return NextResponse.json({
success: true,
message: `¡${prodName} publicado!`,
product: added,
});
}
if (action === 'bulk-publish' && Array.isArray(posts)) {
const createdProducts = [];
for (const p of posts) {
const retailPrice =
p.extractedProduct?.suggestedRetailPrice ||
(p.extractedProduct?.supplierPrice ? p.extractedProduct.supplierPrice + 35000 : 125000);
const rawProdName = p.extractedProduct?.name || 'Artículo Urbano';
const prodBrand = p.extractedProduct?.brand || 'Urbano / Variado';
const forbiddenContext = [p.channelName, p.supplierName];
const prodName = sanitizePublicationTitle(rawProdName, forbiddenContext, prodBrand);
const prodCategory = p.extractedProduct?.category || 'Zapatillas';
const proof = getProductSocialProof({ name: prodName, brand: prodBrand });
const prodDescription = generateAttractiveProductCopy({
name: prodName,
brand: prodBrand,
category: prodCategory,
retailPrice,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
forbiddenTerms: forbiddenContext,
});
const whatsappMsgId = p.whatsappMessageId || p.id.replace('post-', '');
const newProd = {
id: `snk-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
name: prodName,
modelName: prodName,
brand: prodBrand,
supplierPrice: p.extractedProduct?.supplierPrice || 75000,
retailPrice,
originalPrice: Math.round(retailPrice * 1.35),
marketPriceCOP: p.extractedProduct?.marketPriceCOP,
marketPriceSource: p.extractedProduct?.marketPriceSource,
marketPriceSourceUrl: p.extractedProduct?.marketPriceSourceUrl,
marketComparisonSummary: p.extractedProduct?.marketComparisonSummary,
sizes: p.extractedProduct?.sizes || ['37', '38', '39', '40', '41', '42', '43'],
images: Array.isArray(p.rawImages) && p.rawImages.length > 0
? p.rawImages.map((img: string) => {
if (img.startsWith('data:image') && whatsappMsgId) {
return `/api/whatsapp-media?id=${encodeURIComponent(whatsappMsgId)}&remoteJid=${encodeURIComponent(p.channelJid || '')}`;
}
return img;
})
: ['https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'],
description: prodDescription,
category: prodCategory,
inStock: true,
isActive: true,
rating: proof.rating,
reviewsCount: proof.reviewsCount,
salesCount: proof.salesCount,
originBadge: proof.badge,
supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista',
supplierPhone: p.supplierPhone || '573167455877',
whatsappMessageId: whatsappMsgId,
};
const added = dataService.addProduct(newProd as any);
await saveProductToAppwrite(added);
await updateSupplierPostStatusInAppwrite(p.id, 'published');
dataService.updateSupplierPostStatus(p.id, 'published');
createdProducts.push(added);
}
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos!`,
products: createdProducts,
});
}
if (action === 'discard' && post?.id) {
await updateSupplierPostStatusInAppwrite(post.id, 'discarded');
dataService.updateSupplierPostStatus(post.id, 'discarded');
return NextResponse.json({
success: true,
message: `Mensaje [${post.whatsappMessageId || post.id}] descartado y archivado.`,
});
}
if (action === 'bulk-discard' && Array.isArray(posts)) {
const BATCH_SIZE = 10;
for (let i = 0; i < posts.length; i += BATCH_SIZE) {
const batch = posts.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (p) => {
await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
dataService.updateSupplierPostStatus(p.id, 'discarded');
})
);
}
return NextResponse.json({
success: true,
message: `¡${posts.length} mensajes descartados y archivados con éxito!`,
});
}
if (action === 'discard-group' && (body.groupJid || body.groupName)) {
const { groupJid, groupName } = body;
const allSaved = await getSupplierPostsFromAppwrite();
const matchingPosts = allSaved.filter(
(p: SupplierPost) =>
(groupJid && p.channelJid === groupJid) ||
(groupName && (p.channelName === groupName || p.supplierName === groupName))
);
const BATCH_SIZE = 10;
for (let i = 0; i < matchingPosts.length; i += BATCH_SIZE) {
const batch = matchingPosts.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (p: SupplierPost) => {
await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
dataService.updateSupplierPostStatus(p.id, 'discarded');
})
);
}
if (groupJid) {
dataService.toggleGroupMonitoring(groupJid, false);
}
return NextResponse.json({
success: true,
message: `¡Se han descartado y archivado ${matchingPosts.length} mensajes del grupo!`,
discardedCount: matchingPosts.length,
});
}
if (action === 'market-research' && post) {
const img = post.rawImages && post.rawImages[0];
const res = await performMarketIntelligenceSearch(img, post.rawMessage, post.extractedProduct?.supplierPrice);
post.extractedProduct = {
...post.extractedProduct,
...res,
};
post.marketResearchDone = true;
await saveSupplierPostToAppwrite(post);
return NextResponse.json({
success: true,
message: '¡Búsqueda visual e inteligencia de precios completada con éxito!',
extractedProduct: post.extractedProduct,
});
}
return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 });
} catch (err: any) {
console.error('Error en POST /api/whatsapp-feed:', err);
return NextResponse.json({ success: false, error: err.message }, { status: 500 });
}
}