feat(feed): control por identificador de whatsapp para excluir mensajes publicados y descartados y avanzar lista

This commit is contained in:
AI Bot 2026-09-03 11:51:47 -05:00
parent 2e5ea56007
commit b7e920dcaf
8 changed files with 439 additions and 84 deletions

View File

@ -44,7 +44,7 @@ export default function SupplierFeedPage() {
const [monitoredGroups, setMonitoredGroups] = useState<MonitoredWhatsAppGroup[]>([]);
const [existingProducts, setExistingProducts] = useState<Product[]>([]);
const [selectedGroupFilter, setSelectedGroupFilter] = useState<string>('all');
const [tabStatus, setTabStatus] = useState<'all' | 'pending' | 'published' | 'discarded'>('all');
const [tabStatus, setTabStatus] = useState<'pending' | 'published' | 'discarded' | 'all'>('pending');
const [isLoading, setIsLoading] = useState(false);
const [isSyncingLive, setIsSyncingLive] = useState(false);
const [actionMessage, setActionMessage] = useState<string | null>(null);
@ -77,7 +77,7 @@ export default function SupplierFeedPage() {
// Target brand selector for publishing
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
// Cargar grupos monitoreados, productos existentes y posts en vivo
// Cargar grupos monitoreados, productos existentes y posts en vivo (por defecto PENDIENTES)
const loadInitialData = async () => {
setIsLoading(true);
try {
@ -95,8 +95,8 @@ export default function SupplierFeedPage() {
setExistingProducts(prodData.products);
}
// 3. Posts en vivo desde los grupos de WhatsApp (todos los mensajes: leídos y sin leer)
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=60');
// 3. Posts en vivo desde los grupos de WhatsApp (iniciando con PENDIENTES para que la lista avance)
const feedRes = await fetch('/api/whatsapp-feed?fetchLive=true&limit=60&status=pending');
const feedData = await feedRes.json();
if (feedData.success && Array.isArray(feedData.posts)) {
setPosts(feedData.posts);
@ -120,13 +120,13 @@ export default function SupplierFeedPage() {
try {
const url =
groupJid !== 'all'
? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(groupJid)}`
: '/api/whatsapp-feed?fetchLive=true&limit=60';
? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(groupJid)}&status=${tabStatus}`
: `/api/whatsapp-feed?fetchLive=true&limit=60&status=${tabStatus}`;
const res = await fetch(url);
const data = await res.json();
if (data.success && Array.isArray(data.posts)) {
setPosts(data.posts);
setActionMessage(`✅ Se cargaron ${data.totalFound || data.posts.length} mensajes (leídos y sin leer) del grupo.`);
setActionMessage(`✅ Se cargaron ${data.totalFound || data.posts.length} mensajes.`);
}
} catch (err) {
console.error(err);
@ -143,15 +143,15 @@ export default function SupplierFeedPage() {
try {
const url =
selectedGroupFilter !== 'all'
? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(selectedGroupFilter)}`
: '/api/whatsapp-feed?fetchLive=true&limit=60';
? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(selectedGroupFilter)}&status=${tabStatus}`
: `/api/whatsapp-feed?fetchLive=true&limit=60&status=${tabStatus}`;
const res = await fetch(url);
const data = await res.json();
if (data.success && Array.isArray(data.posts)) {
setPosts(data.posts);
setActionMessage(
`✅ ¡Sincronización completa! Se escanearon ${data.totalFound || data.posts.length} mensajes de WhatsApp (leídos y sin leer) y se sincronizaron con Appwrite.`
`✅ ¡Sincronización completa! Se escanearon los mensajes de WhatsApp y se omitieron los ya publicados y descartados.`
);
} else {
setActionMessage('⚠️ No se encontraron mensajes nuevos en los grupos seleccionados.');
@ -284,7 +284,7 @@ export default function SupplierFeedPage() {
}
};
// Descartar mensaje
// Descartar mensaje individual
const handleDiscardPost = async (post: SupplierPost) => {
try {
await fetch('/api/whatsapp-feed', {
@ -300,8 +300,45 @@ export default function SupplierFeedPage() {
next.delete(post.id);
return next;
});
setActionMessage(`🗑️ Mensaje #${post.whatsappMessageId || post.id.replace('post-', '')} descartado y archivado. No se volverá a mostrar en pendientes.`);
} catch (e) {
console.error(e);
setActionMessage('❌ Error al descartar mensaje.');
}
};
// Descarte masivo de mensajes seleccionados
const handleBulkDiscard = async () => {
const selectedPosts = posts.filter(
(p) => selectedPostIds.has(p.id) && p.status !== 'discarded'
);
if (selectedPosts.length === 0) return;
setIsLoading(true);
try {
const res = await fetch('/api/whatsapp-feed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'bulk-discard',
posts: selectedPosts,
}),
});
const data = await res.json();
if (data.success) {
setPosts((prev) =>
prev.map((p) => (selectedPostIds.has(p.id) ? { ...p, status: 'discarded' } : p))
);
setSelectedPostIds(new Set());
setActionMessage(
`🗑️ Se han descartado ${selectedPosts.length} mensajes. No volverán a aparecer en la lista de pendientes.`
);
}
} catch (e) {
console.error(e);
setActionMessage('❌ Error en descarte masivo.');
} finally {
setIsLoading(false);
}
};
@ -315,6 +352,24 @@ export default function SupplierFeedPage() {
});
};
// Cambiar pestaña y actualizar vista
const handleTabChange = async (status: 'pending' | 'published' | 'discarded' | 'all') => {
setTabStatus(status);
setSelectedPostIds(new Set());
setCurrentPage(1);
try {
const url =
selectedGroupFilter !== 'all'
? `/api/whatsapp-feed?fetchLive=true&groupJid=${encodeURIComponent(selectedGroupFilter)}&status=${status}`
: `/api/whatsapp-feed?fetchLive=true&limit=60&status=${status}`;
const res = await fetch(url);
const data = await res.json();
if (data.success && Array.isArray(data.posts)) {
setPosts(data.posts);
}
} catch (_) {}
};
// Filtrado de posts por estado y grupo
const filteredPosts = useMemo(() => {
return posts.filter((p) => {
@ -445,7 +500,7 @@ export default function SupplierFeedPage() {
</button>
<button
onClick={() => setTabStatus('pending')}
onClick={() => handleTabChange('pending')}
className={`px-3 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'pending'
? 'bg-purple-600 text-white shadow'
@ -459,7 +514,7 @@ export default function SupplierFeedPage() {
</button>
<button
onClick={() => setTabStatus('published')}
onClick={() => handleTabChange('published')}
className={`px-3 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'published'
? 'bg-emerald-600 text-white shadow'
@ -473,7 +528,7 @@ export default function SupplierFeedPage() {
</button>
<button
onClick={() => setTabStatus('discarded')}
onClick={() => handleTabChange('discarded')}
className={`px-3 py-2 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
tabStatus === 'discarded'
? 'bg-rose-600 text-white shadow'
@ -518,10 +573,18 @@ export default function SupplierFeedPage() {
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-xs text-neutral-400 hover:text-white px-3 py-1.5"
className="text-xs text-neutral-400 hover:text-white px-3 py-1.5 cursor-pointer"
>
Deseleccionar
</button>
<button
onClick={handleBulkDiscard}
disabled={isLoading}
className="bg-neutral-800 hover:bg-rose-950/80 border border-neutral-700 hover:border-rose-800 text-neutral-300 hover:text-rose-200 font-bold text-xs px-3.5 py-2 rounded-xl flex items-center gap-1.5 cursor-pointer transition-all"
>
<Trash2 className="w-4 h-4 text-rose-400" />
<span>Descartar Seleccionados</span>
</button>
<button
onClick={handleBulkPublish}
disabled={isLoading}
@ -643,11 +706,19 @@ export default function SupplierFeedPage() {
<span className="text-[10px] font-bold text-purple-400 uppercase tracking-wider">
{ext?.brand || 'Sneakers'}
</span>
{ext?.styleCode && (
<span className="text-[9px] font-mono bg-neutral-800 text-neutral-300 px-1.5 py-0.5 rounded border border-neutral-700">
SKU: {ext.styleCode}
<div className="flex items-center gap-1.5">
<span
className="text-[9px] font-mono bg-neutral-950 text-neutral-400 px-2 py-0.5 rounded-md border border-neutral-800/80"
title={`ID WhatsApp: ${post.whatsappMessageId || post.id.replace('post-', '')}`}
>
#{(post.whatsappMessageId || post.id.replace('post-', '')).slice(-8)}
</span>
)}
{ext?.styleCode && (
<span className="text-[9px] font-mono bg-neutral-800 text-neutral-300 px-1.5 py-0.5 rounded border border-neutral-700">
SKU: {ext.styleCode}
</span>
)}
</div>
</div>
<h3 className="font-bold text-white text-sm mt-0.5 line-clamp-1">
{ext?.name || 'Zapatilla Urbana'}

View File

@ -10,6 +10,7 @@ import {
saveSupplierPostToAppwrite,
updateSupplierPostStatusInAppwrite,
saveProductToAppwrite,
getProductsFromAppwrite,
} from '@/lib/server-appwrite';
import { SupplierPost, MonitoredWhatsAppGroup } from '@/types';
@ -23,9 +24,41 @@ export async function GET(request: Request) {
const fetchLive = searchParams.get('fetchLive') !== 'false';
const limit = parseInt(searchParams.get('limit') || '60', 10);
const filterJid = searchParams.get('groupJid');
const statusFilter = (searchParams.get('status') || 'pending').toLowerCase();
// 1. Obtener posts guardados en Appwrite
const savedPosts = await getSupplierPostsFromAppwrite();
// 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();
@ -57,7 +90,7 @@ export async function GET(request: Request) {
console.warn('Error resolviendo chats de WhatsApp:', e);
}
// 3. Determinar JIDs objetivo a consultar (traer TODOS los mensajes de los grupos)
// 3. Determinar JIDs objetivo a consultar
let targetJids: string[] = [];
if (filterJid && filterJid !== 'all') {
targetJids = [filterJid];
@ -73,20 +106,34 @@ export async function GET(request: Request) {
}
if (!fetchLive || targetJids.length === 0) {
const filteredSaved = savedPosts.filter((p) => {
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;
});
return NextResponse.json({
success: true,
isLive: false,
totalFound: savedPosts.length,
posts: savedPosts.length > 0 ? savedPosts : dataService.getSupplierPosts(),
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 cada grupo objetivo en paralelo (TODOS los mensajes sin distinción de leídos o sin leer)
// 4. Consultar mensajes de cada grupo en paralelo desde Evolution API
const groupFetchPromises = targetJids.map(async (jid) => {
try {
const fetchLimit = Math.max(limit * 2, 80);
const msgRes = await fetch(
`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`,
`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${fetchLimit}`,
{
method: 'POST',
headers: {
@ -111,15 +158,25 @@ export async function GET(request: Request) {
}
}
// 5. Ordenar cronológicamente (más recientes primero)
// Ordenar cronológicamente (más recientes primero)
allRecords.sort((a, b) => (b.messageTimestamp || 0) - (a.messageTimestamp || 0));
// 6. Procesar cada mensaje y descargar imágenes / IA
// 5. Procesar mensajes con control estricto de identificador de WhatsApp
const processedPosts: SupplierPost[] = [];
const sliceRecords = allRecords.slice(0, 60);
const seenMessageIds = new Set<string>();
for (const record of sliceRecords) {
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 ||
@ -130,15 +187,46 @@ export async function GET(request: Request) {
const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista';
const hasImage = Boolean(record.message?.imageMessage);
// Si no tiene texto ni imagen, omitir
if (!text && !hasImage) continue;
const postId = `post-${record.key?.id || Date.now()}`;
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)
);
// Verificar si ya existe en Appwrite para preservar su estado de publicación o descarte
const existingInAppwrite = savedPosts.find(
(p) => p.id === postId || (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[] = [];
@ -169,10 +257,8 @@ export async function GET(request: Request) {
);
}
// Si ya existía con datos extraídos en Appwrite, usarlo directamente asegurando sanitización
let extracted = existingInAppwrite?.extractedProduct;
if (!extracted) {
// NUNCA pasar groupName como texto de calzado. Si text está vacío, pasar string vacío para que Gemini use la imagen y visión sin contaminar el nombre del producto
const cleanInputText = text || '';
extracted = await parseSupplierMessage(cleanInputText, imageUrls[0], [groupName]);
} else if (extracted && extracted.name) {
@ -190,12 +276,13 @@ export async function GET(request: Request) {
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: existingInAppwrite?.status || 'pending_review',
status: effectiveStatus,
receivedAt: timestamp,
supplierName: groupName,
supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573167455877',
@ -203,26 +290,23 @@ export async function GET(request: Request) {
};
processedPosts.push(post);
// Persistir de forma no bloqueante en Appwrite
saveSupplierPostToAppwrite(post).catch(() => {});
if (processedPosts.length >= limit) break;
}
const mergedPosts = [...processedPosts];
// Agregar los posts de Appwrite que no hayan estado en esta ráfaga en vivo
savedPosts.forEach((sp) => {
if (!mergedPosts.some((p) => p.id === sp.id)) {
mergedPosts.push(sp);
}
});
dataService.saveSupplierPosts(mergedPosts);
return NextResponse.json({
success: true,
isLive: true,
totalFound: mergedPosts.length,
posts: mergedPosts,
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) {
@ -246,7 +330,6 @@ export async function POST(request: Request) {
marketIntelligence,
} = body;
// 1. PUBLICAR UN PRODUCTO A LA TIENDA (APP MÓVIL Y WEB)
if (action === 'publish' && post) {
const retailPrice =
customRetailPrice ||
@ -267,6 +350,8 @@ export async function POST(request: Request) {
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,
@ -292,6 +377,7 @@ export async function POST(request: Request) {
originBadge: proof.badge,
supplierName: post.supplierName || post.channelName || 'Proveedor Mayorista',
supplierPhone: post.supplierPhone || '573167455877',
whatsappMessageId: whatsappMsgId,
};
const added = dataService.addProduct(newProduct as any);
@ -301,12 +387,11 @@ export async function POST(request: Request) {
return NextResponse.json({
success: true,
message: `¡${prodName} publicado y sincronizado en Appwrite, App Móvil y Web!`,
message: `¡${prodName} publicado!`,
product: added,
});
}
// 2. PUBLICACIÓN MASIVA (BULK)
if (action === 'bulk-publish' && Array.isArray(posts)) {
const createdProducts = [];
for (const p of posts) {
@ -328,6 +413,8 @@ export async function POST(request: Request) {
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,
@ -352,6 +439,7 @@ export async function POST(request: Request) {
originBadge: proof.badge,
supplierName: p.supplierName || p.channelName || 'Proveedor Mayorista',
supplierPhone: p.supplierPhone || '573167455877',
whatsappMessageId: whatsappMsgId,
};
const added = dataService.addProduct(newProd as any);
@ -363,19 +451,31 @@ export async function POST(request: Request) {
return NextResponse.json({
success: true,
message: `¡Se publicaron ${createdProducts.length} productos con persistencia en Appwrite!`,
message: `¡Se publicaron ${createdProducts.length} productos!`,
products: createdProducts,
});
}
// 3. DESCARTAR POST
if (action === 'discard' && post?.id) {
await updateSupplierPostStatusInAppwrite(post.id, 'discarded');
dataService.updateSupplierPostStatus(post.id, 'discarded');
return NextResponse.json({ success: true, message: 'Mensaje descartado' });
return NextResponse.json({
success: true,
message: `Mensaje [${post.whatsappMessageId || post.id}] descartado y archivado.`,
});
}
if (action === 'bulk-discard' && Array.isArray(posts)) {
for (const p of posts) {
await updateSupplierPostStatusInAppwrite(p.id, 'discarded');
dataService.updateSupplierPostStatus(p.id, 'discarded');
}
return NextResponse.json({
success: true,
message: `¡${posts.length} mensajes descartados y archivados con éxito!`,
});
}
// 4. BÚSQUEDA VISUAL E INTELIGENCIA DE MERCADO SOBRE POST
if (action === 'market-research' && post) {
const img = post.rawImages && post.rawImages[0];
const res = await performMarketIntelligenceSearch(img, post.rawMessage, post.extractedProduct?.supplierPrice);

View File

@ -543,6 +543,7 @@ export async function getProductsFromAppwrite(): Promise<Product[]> {
isActive: d.isActive !== false,
supplierName: d.supplierName || '',
supplierPhone: d.supplierPhone || '',
whatsappMessageId: d.whatsappMessageId || '',
};
});
}
@ -574,6 +575,7 @@ export async function saveProductToAppwrite(product: Product): Promise<Product>
isActive: product.isActive !== false,
supplierName: product.supplierName || '',
supplierPhone: product.supplierPhone || '',
whatsappMessageId: product.whatsappMessageId || '',
};
try {
@ -620,6 +622,7 @@ export async function getSupplierPostsFromAppwrite(): Promise<SupplierPost[]> {
return {
id: d.$id,
whatsappMessageId: d.whatsappMessageId || d.$id.replace('post-', ''),
channelName: d.channelName || '',
channelJid: d.channelJid || '',
supplierName: d.supplierName || '',
@ -639,7 +642,9 @@ export async function getSupplierPostsFromAppwrite(): Promise<SupplierPost[]> {
}
export async function saveSupplierPostToAppwrite(post: SupplierPost): Promise<SupplierPost> {
const whatsappMessageId = post.whatsappMessageId || post.id.replace('post-', '');
const payload = {
whatsappMessageId,
channelName: post.channelName || 'Grupo WhatsApp',
channelJid: post.channelJid || '',
supplierName: post.supplierName || '',
@ -653,22 +658,39 @@ export async function saveSupplierPostToAppwrite(post: SupplierPost): Promise<Su
try {
await serverDatabases.createDocument(DB_ID, 'supplier_posts', post.id, payload);
return post;
return { ...post, whatsappMessageId };
} catch (e: any) {
if (e.code === 409) {
try {
await serverDatabases.updateDocument(DB_ID, 'supplier_posts', post.id, payload);
} catch (_) {}
}
return post;
return { ...post, whatsappMessageId };
}
}
export async function updateSupplierPostStatusInAppwrite(id: string, status: SupplierPost['status']): Promise<boolean> {
export async function updateSupplierPostStatusInAppwrite(
id: string,
status: SupplierPost['status']
): Promise<boolean> {
const cleanId = id.trim();
const whatsappMessageId = cleanId.replace('post-', '');
try {
await serverDatabases.updateDocument(DB_ID, 'supplier_posts', id, { status });
await serverDatabases.updateDocument(DB_ID, 'supplier_posts', cleanId, {
status,
whatsappMessageId,
});
return true;
} catch (e) {
return false;
try {
await serverDatabases.createDocument(DB_ID, 'supplier_posts', cleanId, {
status,
whatsappMessageId,
receivedAt: new Date().toISOString(),
});
return true;
} catch (_) {
return false;
}
}
}

View File

@ -78,6 +78,7 @@ export interface Product {
supplierName?: string;
supplierPhone?: string;
supplierSku?: string;
whatsappMessageId?: string;
}
@ -145,6 +146,7 @@ export interface Order {
export interface SupplierPost {
id: string;
whatsappMessageId?: string;
channelName: string;
channelJid?: string;
supplierName?: string;
@ -154,6 +156,7 @@ export interface SupplierPost {
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
publishedAt?: string;
isRead?: boolean;
marketResearchDone?: boolean;
}

View File

@ -16,7 +16,7 @@ async function sendApkToCarlos() {
sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
}
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado (Build Ultra-Rápido)*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n⚡ *Novedades de Rendimiento & Optimización:*\n🖼️ *Optimización Total de Imágenes & WebP:* Motor inteligente de compresión automática para Appwrite Storage, Unsplash y CDNs. Reduce el peso de las imágenes hasta un 85% con carga diferida (\`loading="lazy"\`), decodificación asíncrona y skeletons animados de precarga (\`shimmer\`) para eliminar parpadeos y retrasos.\n📑 *Paginación Dinámica en la App & Web:* Catálogo principal con selector de zapatillas por página (\`12 / 24 / 48\`), navegación interactiva de páginas, contador de modelos y desplazamiento automático suave al cambiar de página.\n📊 *Paginación en Paneles Admin:* Tablas de productos y feed de WhatsApp optimizadas con paginación integrada para fluidez instantánea sin congelar la pantalla.\n🚀 *Despliegue en VPS:* Servidor de producción actualizado y activo en \`https://dripzone.almeira.com.co\`.\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado (Privacidad & Sanitización Total)*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n🔒 *Novedades de Privacidad Comercial & Sanitización:*\n🛡️ *Cero Nombres de Grupos o Proveedores:* Se sanitizan estrictamente títulos y textos de todas las publicaciones (IA multimodal, WhatsApp Feed, panel admin y Appwrite). Ni en el título ni en la descripción aparece jamás el nombre del grupo de WhatsApp ni el proveedor o bodega.\n💰 *Exclusión Total del Costo del Producto:* Se prohíbe y elimina automáticamente cualquier mención al costo o precio mayorista de compra en títulos, copys y textos públicos. Solo se extrae como dato numérico para cálculo de margen interno en el panel administrativo.\n🚀 *Gitea Sincronizado:* Cambios enviados y respaldados en el servidor de repositorios Gitea (\`gitea.almeira.com.co\`).\n\n📲 *Descarga Directa del APK:* https://dripzone.almeira.com.co/DripZone.apk\n\nDescarga y abre el archivo APK para actualizar la app en tu Android.`;
console.log(`📤 [1/2] Enviando notificación oficial a WhatsApp (+${TARGET_PHONE})...`);
try {

View File

@ -0,0 +1,74 @@
import urllib.request
import json
import ssl
import sys
import io
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
EVOLUTION_API_URL = "https://evolution.almeira.com.co"
EVOLUTION_API_KEY = "EE49029EEB85-4568-8F81-C9FDFC6E2943"
INSTANCE_NAME = "AutomataAlmeira"
TARGET_PHONE = "573167455877"
APK_URL = "https://dripzone.almeira.com.co/api/download-apk"
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
def send_request(endpoint, payload):
url = f"{EVOLUTION_API_URL}{endpoint}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={
"apikey": EVOLUTION_API_KEY,
"Content-Type": "application/json"
})
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as response:
return response.status, response.read().decode("utf-8")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8")
except Exception as e:
return 0, str(e)
def send_update():
message_text = (
"🚀 *DripZone Sneakers - APK Oficial Actualizado (Privacidad Total)*\n\n"
"🔒 *Novedades de Privacidad Comercial & Sanitización:*\n"
"🛡️ *Cero Nombres de Grupos o Proveedores:* Se sanitizan estrictamente títulos y textos en toda la plataforma "
"(IA multimodal, feed de WhatsApp, panel administrativo y Appwrite). Ni en el título ni en la descripción figura jamás "
"el nombre del grupo de WhatsApp ni el proveedor/bodega.\n\n"
"💰 *Exclusión Total del Costo del Producto:* Se prohíbe y elimina automáticamente cualquier mención al costo o precio mayorista "
"de compra en títulos, copys y textos públicos. El costo solo se almacena internamente para control de márgenes.\n\n"
"⚡ *Gitea Sincronizado:* Código respaldado en el servidor Gitea (`gitea.almeira.com.co`).\n"
"🌐 *VPS Desplegado:* Nueva versión activa en `https://dripzone.almeira.com.co`.\n\n"
f"📲 *Enlace directo al APK:* {APK_URL}\n\n"
"Descarga el archivo adjunto para instalar o actualizar la aplicación en tu dispositivo Android."
)
print(f"📤 [1/2] Enviando mensaje de texto a WhatsApp (+{TARGET_PHONE})...")
payload_text = {
"number": TARGET_PHONE,
"text": message_text,
"delay": 1000
}
status, res = send_request(f"/message/sendText/{INSTANCE_NAME}", payload_text)
print(" -> Respuesta de texto:", status, res[:200] if res else "")
print(f"📤 [2/2] Enviando documento APK a WhatsApp (+{TARGET_PHONE})...")
payload_media = {
"number": TARGET_PHONE,
"mediatype": "document",
"mimetype": "application/vnd.android.package-archive",
"fileName": "DripZone.apk",
"caption": "📲 *DripZone.apk - Versión Oficial Actualizada*",
"media": APK_URL
}
status, res = send_request(f"/message/sendMedia/{INSTANCE_NAME}", payload_media)
print(" -> Respuesta de APK:", status, res[:200] if res else "")
if __name__ == "__main__":
send_update()

View File

@ -15,12 +15,33 @@ export async function GET(request: Request) {
const fetchLive = searchParams.get('fetchLive') !== 'false';
const limit = parseInt(searchParams.get('limit') || '50', 10);
const filterJid = searchParams.get('groupJid');
const statusFilter = (searchParams.get('status') || 'pending').toLowerCase();
// 1. 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));
// Obtener posts almacenados localmente para control de IDs
const localPosts = dataService.getSupplierPosts();
const publishedMsgIds = new Set<string>();
const discardedMsgIds = new Set<string>();
const localPostMap = new Map<string, SupplierPost>();
localPosts.forEach((p) => {
localPostMap.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(p.id);
} else if (p.status === 'discarded' || p.status === 'ignored') {
discardedMsgIds.add(msgId);
discardedMsgIds.add(p.id);
}
}
});
let liveGroups: any[] = [];
try {
const chatsRes = await fetch(`${EVOLUTION_API_URL}/chat/findChats/${EVOLUTION_INSTANCE_NAME}`, {
@ -61,12 +82,19 @@ export async function GET(request: Request) {
}
}
if (targetJids.length === 0) {
if (targetJids.length === 0 || !fetchLive) {
const filtered = localPosts.filter((p) => {
if (statusFilter === 'pending') return p.status === 'pending_review';
if (statusFilter === 'published') return p.status === 'published';
if (statusFilter === 'discarded') return p.status === 'discarded';
return true;
});
return NextResponse.json({
success: true,
isLive: true,
totalFound: 0,
posts: dataService.getSupplierPosts(),
isLive: false,
totalFound: filtered.length,
posts: filtered,
groups: Array.from(groupsMap.entries()).map(([jid, name]) => ({ jid, name })),
});
}
@ -74,8 +102,9 @@ export async function GET(request: Request) {
// 3. Consultar mensajes de cada grupo objetivo en paralelo
const groupFetchPromises = targetJids.map(async (jid) => {
try {
const fetchLimit = Math.max(limit * 2, 80);
const msgRes = await fetch(
`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${limit}`,
`${EVOLUTION_API_URL}/chat/findMessages/${EVOLUTION_INSTANCE_NAME}?page=1&limit=${fetchLimit}`,
{
method: 'POST',
headers: {
@ -105,12 +134,20 @@ export async function GET(request: Request) {
// 5. Procesar cada mensaje y descargar imágenes/IA
const processedPosts: SupplierPost[] = [];
const seenMessageIds = new Set<string>();
// Limitar a los 60 más relevantes para rendimiento óptimo
const sliceRecords = allRecords.slice(0, 60);
for (const record of sliceRecords) {
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 ||
@ -121,9 +158,38 @@ export async function GET(request: Request) {
const groupName = groupsMap.get(remoteJid) || record.pushName || 'Grupo Mayorista';
const hasImage = Boolean(record.message?.imageMessage);
// Si no tiene texto ni imagen, omitir
if (!text && !hasImage) continue;
const existingInLocal =
localPostMap.get(postId) ||
localPosts.find((p) => p.id === postId || p.whatsappMessageId === rawMsgId || p.whatsappMessageId === cleanMsgId);
const isPublished =
existingInLocal?.status === 'published' ||
publishedMsgIds.has(rawMsgId) ||
publishedMsgIds.has(cleanMsgId) ||
publishedMsgIds.has(postId);
const isDiscarded =
existingInLocal?.status === 'discarded' ||
discardedMsgIds.has(rawMsgId) ||
discardedMsgIds.has(cleanMsgId) ||
discardedMsgIds.has(postId);
const effectiveStatus: SupplierPost['status'] = isPublished
? 'published'
: isDiscarded
? 'discarded'
: 'pending_review';
if (statusFilter === 'pending' && (isPublished || isDiscarded)) {
continue;
} else if (statusFilter === 'published' && !isPublished) {
continue;
} else if (statusFilter === 'discarded' && !isDiscarded) {
continue;
}
let imageUrls: string[] = [];
// Descargar imagen en Base64 desde Evolution API si está presente
@ -167,31 +233,39 @@ export async function GET(request: Request) {
: 'Reciente';
const post: SupplierPost = {
id: `post-${record.key?.id || Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
id: postId,
whatsappMessageId: rawMsgId || cleanMsgId,
channelName: groupName,
channelJid: remoteJid,
rawMessage: text || `[Foto de Calzado enviada en ${groupName}]`,
rawImages: imageUrls,
extractedProduct: extracted,
status: 'pending_review',
status: effectiveStatus,
receivedAt: timestamp,
supplierName: groupName,
supplierPhone: record.key?.participant?.replace(/\D/g, '') || '573167455877',
};
processedPosts.push(post);
}
dataService.addSupplierPost(post);
// Guardar en el servicio local los nuevos posts
if (processedPosts.length > 0) {
dataService.saveSupplierPosts(processedPosts);
if (processedPosts.length >= limit) {
break;
}
}
return NextResponse.json({
success: true,
isLive: true,
statusFilter,
totalFound: processedPosts.length,
posts: processedPosts.length > 0 ? processedPosts : dataService.getSupplierPosts(),
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) {
@ -298,7 +372,15 @@ export async function POST(request: Request) {
// 3. DESCARTAR POST
if (action === 'discard' && post?.id) {
dataService.updateSupplierPostStatus(post.id, 'discarded');
return NextResponse.json({ success: true, message: 'Mensaje descartado' });
return NextResponse.json({ success: true, message: 'Mensaje descartado y archivado' });
}
// 4. DESCARTE MASIVO (BULK DISCARD)
if (action === 'bulk-discard' && Array.isArray(posts)) {
for (const p of posts) {
dataService.updateSupplierPostStatus(p.id, 'discarded');
}
return NextResponse.json({ success: true, message: `¡${posts.length} mensajes descartados y archivados!` });
}
return NextResponse.json({ success: false, error: 'Acción no válida' }, { status: 400 });

View File

@ -72,6 +72,7 @@ export interface Product {
supplierName?: string;
supplierPhone?: string;
supplierSku?: string;
whatsappMessageId?: string;
}
export interface City {
@ -138,6 +139,7 @@ export interface Order {
export interface SupplierPost {
id: string;
whatsappMessageId?: string;
channelName: string;
channelJid?: string;
supplierName?: string;
@ -147,6 +149,7 @@ export interface SupplierPost {
extractedProduct?: ExtractedProductData;
status: 'pending_review' | 'published_facebook' | 'published_store' | 'published' | 'discarded' | 'ignored';
receivedAt: string;
publishedAt?: string;
}
export interface ExtractedProductData {