From c7abbd2d54d386389f718b952c568e01f11d4907 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Thu, 3 Sep 2026 19:27:33 -0500 Subject: [PATCH] feat: seleccion masiva de pagina activa y exclusion/descarte inteligente de grupos inactivos o abandonados --- deploy_dripzone_vps.py | 4 + .../src/app/admin/feed-whatsapp/page.tsx | 220 ++++++++++++++++-- .../src/app/api/whatsapp-feed/route.ts | 60 ++++- .../src/app/api/whatsapp-groups/route.ts | 76 ++++-- dripzone-app/src/types/index.ts | 2 + scripts/send_whatsapp_delivery.py | 20 +- .../src/app/api/whatsapp-groups/route.ts | 76 ++++-- zapply-app/src/types/index.ts | 2 + 8 files changed, 377 insertions(+), 83 deletions(-) diff --git a/deploy_dripzone_vps.py b/deploy_dripzone_vps.py index bff8c30..cab5a3a 100644 --- a/deploy_dripzone_vps.py +++ b/deploy_dripzone_vps.py @@ -78,6 +78,10 @@ def deploy(): cd {REMOTE_DIR} docker compose -f docker-compose.prod.yml down --remove-orphans || true DOCKER_BUILDKIT=1 docker compose -f docker-compose.prod.yml up -d --build + sleep 3 + docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/public/DripZone.apk || true + docker cp {REMOTE_DIR}/public/DripZone.apk dripzone-production:/app/DripZone.apk || true + docker exec dripzone-production chmod 644 /app/public/DripZone.apk /app/DripZone.apk || true docker ps | grep dripzone """ diff --git a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx index 932218a..213db81 100644 --- a/dripzone-app/src/app/admin/feed-whatsapp/page.tsx +++ b/dripzone-app/src/app/admin/feed-whatsapp/page.tsx @@ -20,6 +20,9 @@ import { Filter, CheckSquare, Square, + MinusSquare, + AlertCircle, + AlertTriangle, Edit3, Trash2, ArrowUpRight, @@ -383,14 +386,6 @@ export default function SupplierFeedPage() { }); }; - const handleSelectAllCurrentPage = () => { - const next = new Set(selectedPostIds); - paginatedPosts.forEach((p) => { - if (p.status !== 'published') next.add(p.id); - }); - setSelectedPostIds(next); - }; - const handleDeselectAll = () => { setSelectedPostIds(new Set()); }; @@ -550,6 +545,88 @@ export default function SupplierFeedPage() { const publishedCount = posts.filter((p) => p.status === 'published').length; const discardedCount = posts.filter((p) => p.status === 'discarded').length; + const selectedGroupObj = useMemo(() => { + return monitoredGroups.find((g) => g.jid === selectedGroupFilter); + }, [monitoredGroups, selectedGroupFilter]); + + // Selección de página y global + const selectableOnPage = useMemo(() => { + return paginatedPosts.filter((p) => p.status !== 'published' && p.status !== 'discarded'); + }, [paginatedPosts]); + + const isAllPageSelected = useMemo(() => { + if (selectableOnPage.length === 0) return false; + return selectableOnPage.every((p) => selectedPostIds.has(p.id)); + }, [selectableOnPage, selectedPostIds]); + + const somePageSelected = useMemo(() => { + return selectableOnPage.some((p) => selectedPostIds.has(p.id)); + }, [selectableOnPage, selectedPostIds]); + + const handleToggleSelectPage = () => { + setSelectedPostIds((prev) => { + const next = new Set(prev); + if (isAllPageSelected) { + selectableOnPage.forEach((p) => next.delete(p.id)); + } else { + selectableOnPage.forEach((p) => next.add(p.id)); + } + return next; + }); + }; + + const handleSelectAllFiltered = () => { + const selectableFiltered = filteredPosts.filter((p) => p.status !== 'published' && p.status !== 'discarded'); + setSelectedPostIds((prev) => { + const next = new Set(prev); + selectableFiltered.forEach((p) => next.add(p.id)); + return next; + }); + }; + + const handleDiscardGroupMessages = async (groupJid: string, groupName: string) => { + const confirmMsg = `¿Deseas descartar y archivar TODOS los mensajes del grupo "${groupName}"? No volverán a aparecer en pendientes.`; + if (!window.confirm(confirmMsg)) return; + + setIsLoading(true); + try { + const res = await fetch('/api/whatsapp-feed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'discard-group', + groupJid, + groupName, + }), + }); + const data = await res.json(); + if (data.success) { + setPosts((prev) => + prev.map((p) => + (groupJid && p.channelJid === groupJid) || (groupName && p.channelName === groupName) + ? { ...p, status: 'discarded' } + : p + ) + ); + setSelectedPostIds((prev) => { + const next = new Set(prev); + posts.forEach((p) => { + if ((groupJid && p.channelJid === groupJid) || (groupName && p.channelName === groupName)) { + next.delete(p.id); + } + }); + return next; + }); + setActionMessage(`🗑️ Se han descartado ${data.discardedCount ?? 'todos los'} mensajes de "${groupName}".`); + } + } catch (e) { + console.error(e); + setActionMessage('❌ Error al descartar mensajes del grupo.'); + } finally { + setIsLoading(false); + } + }; + const handleOpenEdit = (post: SupplierPost) => { setEditingPost(post); const prodName = post.extractedProduct?.name || 'Artículo Urbano'; @@ -711,11 +788,14 @@ export default function SupplierFeedPage() { className="bg-neutral-950 border border-neutral-800 text-neutral-300 text-xs rounded-xl px-3 py-2 outline-hidden focus:border-purple-500 max-w-[220px] truncate" > - {monitoredGroups.map((g) => ( - - ))} + {monitoredGroups.map((g) => { + const isInactive = g.hasLeft || g.isStillMember === false; + return ( + + ); + })} @@ -752,38 +832,134 @@ export default function SupplierFeedPage() { })} - {/* Bulk Action Bar */} + {/* Aviso Inteligente si el usuario seleccionó un grupo del que ya salió */} + {selectedGroupObj && (selectedGroupObj.hasLeft || selectedGroupObj.isStillMember === false) && ( +
+
+ +
+

Ya no perteneces a este grupo en WhatsApp

+

+ Saliste de "{selectedGroupObj.name}". Sus mensajes ya no se actualizarán y puedes descartarlos todos con un solo clic para limpiar tu lista. +

+
+
+ +
+ )} + + {/* Barra de Selección de Página y Acciones Rápidas */} +
+
+ {/* Botón Seleccionar Página Actual */} + + + {/* Botón Seleccionar Todo el Filtro */} + {filteredPosts.length > selectableOnPage.length && ( + + )} + + {selectedPostIds.size > 0 && ( + + {selectedPostIds.size} {selectedPostIds.size === 1 ? 'seleccionado' : 'seleccionados'} en total + + )} +
+ + {/* Botón para descartar todo el grupo seleccionado */} + {selectedGroupFilter !== 'all' && selectedGroupObj && ( + + )} +
+ + {/* Bulk Action Bar (Visible cuando hay mensajes seleccionados) */} {selectedPostIds.size > 0 && ( -
+
- + {selectedPostIds.size} {selectedPostIds.size === 1 ? 'mensaje seleccionado' : 'mensajes seleccionados'}
diff --git a/dripzone-app/src/app/api/whatsapp-feed/route.ts b/dripzone-app/src/app/api/whatsapp-feed/route.ts index 6c0ffaa..6e82373 100644 --- a/dripzone-app/src/app/api/whatsapp-feed/route.ts +++ b/dripzone-app/src/app/api/whatsapp-feed/route.ts @@ -91,23 +91,32 @@ export async function GET(request: Request) { console.warn('Error resolviendo chats de WhatsApp:', e); } - // 3. Determinar JIDs objetivo a consultar + // 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).map((g) => g.jid); + 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); + targetJids = liveGroups + .map((g: any) => g.remoteJid || g.id) + .filter((jid: string) => !leftJids.has(jid)); } else { - targetJids = Array.from(groupsMap.keys()); + 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'; @@ -484,9 +493,15 @@ export async function POST(request: Request) { } if (action === 'bulk-discard' && Array.isArray(posts)) { - for (const p of posts) { - await updateSupplierPostStatusInAppwrite(p.id, 'discarded'); - dataService.updateSupplierPostStatus(p.id, 'discarded'); + 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, @@ -494,6 +509,37 @@ export async function POST(request: Request) { }); } + 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); diff --git a/dripzone-app/src/app/api/whatsapp-groups/route.ts b/dripzone-app/src/app/api/whatsapp-groups/route.ts index 30b77b9..b345475 100644 --- a/dripzone-app/src/app/api/whatsapp-groups/route.ts +++ b/dripzone-app/src/app/api/whatsapp-groups/route.ts @@ -40,28 +40,57 @@ export async function GET(request: Request) { ); if (liveGroups.length > 0) { - const merged: MonitoredWhatsAppGroup[] = liveGroups.map((lg: any) => { - const jid = lg.remoteJid || lg.id || lg.jid; - const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp'; - const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase()); + const merged: MonitoredWhatsAppGroup[] = await Promise.all( + liveGroups.map(async (lg: any) => { + const jid = lg.remoteJid || lg.id || lg.jid; + const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp'; + let isStillMember = true; + let participants = undefined; - // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa - const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name); - const isMonitored = existing ? existing.isMonitored : isKnownSupplierGroup; + try { + const infoRes = await fetch( + `${EVOLUTION_API_URL}/group/findGroupInfos/${EVOLUTION_INSTANCE_NAME}?groupJid=${jid}`, + { + headers: { apikey: EVOLUTION_API_KEY }, + signal: AbortSignal.timeout(3500), + } + ); + if (infoRes.status === 404 || infoRes.status === 401 || infoRes.status === 403) { + isStillMember = false; + } else if (infoRes.ok) { + const infoData = await infoRes.json(); + if (Array.isArray(infoData.participants)) { + participants = infoData.participants.length; + } + } + } catch { + // Si falla por timeout, mantener estado previo + } - return { - id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`, - jid, - name, - isMonitored, - supplierId: existing?.supplierId, - supplierName: existing?.supplierName || name, - profitMarginOverride: existing?.profitMarginOverride || 35, - autoPublish: existing?.autoPublish ?? true, - participantsCount: lg.unreadCount !== undefined ? lg.unreadCount : undefined, - notes: existing?.notes || '', - }; - }); + const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase()); + + // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa + const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name); + const isMonitored = existing + ? (existing.isMonitored && isStillMember) + : (isKnownSupplierGroup && isStillMember); + + return { + id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`, + jid, + name, + isMonitored: isStillMember ? isMonitored : false, + isStillMember, + hasLeft: !isStillMember, + supplierId: existing?.supplierId, + supplierName: existing?.supplierName || name, + profitMarginOverride: existing?.profitMarginOverride || 35, + autoPublish: existing?.autoPublish ?? true, + participantsCount: participants ?? lg.unreadCount, + notes: isStillMember ? (existing?.notes || '') : 'Saliste de este grupo en WhatsApp', + }; + }) + ); // Asegurar que los locales sigan presentes localGroups.forEach((lg) => { @@ -70,6 +99,9 @@ export async function GET(request: Request) { } }); + // Guardar estado actualizado en dataService + dataService.saveMonitoredGroups(merged); + return NextResponse.json({ success: true, isLive: true, @@ -95,9 +127,9 @@ export async function POST(request: Request) { try { const body = await request.json(); - if (body.action === 'toggle') { + if (body.action === 'toggle' || body.action === 'deactivate-group') { const { idOrJid, isMonitored } = body; - const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored); + const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored !== undefined ? isMonitored : false); return NextResponse.json({ success: true, group: updated }); } diff --git a/dripzone-app/src/types/index.ts b/dripzone-app/src/types/index.ts index a05fa6a..e1e282e 100644 --- a/dripzone-app/src/types/index.ts +++ b/dripzone-app/src/types/index.ts @@ -224,6 +224,8 @@ export interface MonitoredWhatsAppGroup { autoPublish: boolean; participantsCount?: number; lastMessageAt?: string; + isStillMember?: boolean; + hasLeft?: boolean; notes?: string; } diff --git a/scripts/send_whatsapp_delivery.py b/scripts/send_whatsapp_delivery.py index 77a9321..b65ea1e 100644 --- a/scripts/send_whatsapp_delivery.py +++ b/scripts/send_whatsapp_delivery.py @@ -37,18 +37,18 @@ def send_request(endpoint, payload): def send_update(): message_text = ( "🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n" - "✨ *Acceso Completo a Miles de Mensajes & Paginación Avanzada:*\n" - "📊 *Eliminación del Tope de 60:* Se eliminó la restricción que limitaba la vista a 60 mensajes. " - "Ahora la plataforma escanea en paralelo todas las páginas del historial de los grupos de WhatsApp, " - "cargando cientos o miles de mensajes sin recortes.\n" - "📖 *Paginación Dinámica:* Puedes seleccionar ver 12, 24, 48, 96 o 150 productos por página, " - "con contador total exacto y selector para saltar a cualquier página instantáneamente.\n" - "⚡ *Escaneo Todo el Historial:* Botón especial para explorar a profundidad (+1,000 mensajes) en segundos.\n" - "🆔 *Control de Duplicados & Sanitización:* Se omiten automáticamente los ya publicados y descartados, " - "con privacidad total comercial (sin nombres de grupos ni costos).\n" + "✨ *Identificación y Categorización Inteligente Multi-Producto:*\n" + "👟 *Zapatillas & Sneakers:* Detección de siluetas, modelos urbanos y deportivos.\n" + "🩴 *Chanclas & Sandalias:* Clasificación precisa de chanclas, slides, crocs y pantuflas con textos de confort.\n" + "👜 *Bolsos & Carteras:* Reconocimiento de bolsos de mano, tote bags y carteras con copys persuasivos de lujo.\n" + "🎒 *Maletines & Morrales:* Detección de maletines para portátil, mochilas y tulas deportivas.\n" + "👝 *Carrieles & Bandoleras:* Identificación de carrieles tradicionales, bandoleras, riñoneras, pecheras y crossbody.\n" + "🧢 *Gorras & Accesorios:* Categorización de gorras, caps, sombreros, billeteras y correas.\n" + "🏷️ *Filtro Rápido en Feed Administrativo:* Nueva barra de categorías con conteo en tiempo real y selector en el modal de edición.\n" + "🛍️ *Catálogo de Clientes Adaptado:* Los clientes ahora pueden filtrar por categoría directamente en la tienda.\n" "🌐 *VPS Desplegado:* Contenedor en producción activo en `https://dripzone.almeira.com.co`.\n\n" f"📲 *Descarga directa del APK:* {APK_URL}\n\n" - "Descarga e instala el archivo para disfrutar de la experiencia completa." + "Instala o actualiza el archivo en tu dispositivo Android para disfrutar de estas nuevas funciones." ) print(f"📤 [1/2] Enviando mensaje de texto a WhatsApp (+{TARGET_PHONE})...") diff --git a/zapply-app/src/app/api/whatsapp-groups/route.ts b/zapply-app/src/app/api/whatsapp-groups/route.ts index 30b77b9..b345475 100644 --- a/zapply-app/src/app/api/whatsapp-groups/route.ts +++ b/zapply-app/src/app/api/whatsapp-groups/route.ts @@ -40,28 +40,57 @@ export async function GET(request: Request) { ); if (liveGroups.length > 0) { - const merged: MonitoredWhatsAppGroup[] = liveGroups.map((lg: any) => { - const jid = lg.remoteJid || lg.id || lg.jid; - const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp'; - const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase()); + const merged: MonitoredWhatsAppGroup[] = await Promise.all( + liveGroups.map(async (lg: any) => { + const jid = lg.remoteJid || lg.id || lg.jid; + const name = lg.name || lg.pushName || lg.subject || 'Grupo WhatsApp'; + let isStillMember = true; + let participants = undefined; - // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa - const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name); - const isMonitored = existing ? existing.isMonitored : isKnownSupplierGroup; + try { + const infoRes = await fetch( + `${EVOLUTION_API_URL}/group/findGroupInfos/${EVOLUTION_INSTANCE_NAME}?groupJid=${jid}`, + { + headers: { apikey: EVOLUTION_API_KEY }, + signal: AbortSignal.timeout(3500), + } + ); + if (infoRes.status === 404 || infoRes.status === 401 || infoRes.status === 403) { + isStillMember = false; + } else if (infoRes.ok) { + const infoData = await infoRes.json(); + if (Array.isArray(infoData.participants)) { + participants = infoData.participants.length; + } + } + } catch { + // Si falla por timeout, mantener estado previo + } - return { - id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`, - jid, - name, - isMonitored, - supplierId: existing?.supplierId, - supplierName: existing?.supplierName || name, - profitMarginOverride: existing?.profitMarginOverride || 35, - autoPublish: existing?.autoPublish ?? true, - participantsCount: lg.unreadCount !== undefined ? lg.unreadCount : undefined, - notes: existing?.notes || '', - }; - }); + const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase()); + + // Pre-marcar como monitoreados grupos reconocidos de proveedores si no había configuración previa + const isKnownSupplierGroup = /exclusive|emporio|jovic|diamonds|mayorista|calzado|sneakers|zapatillas/i.test(name); + const isMonitored = existing + ? (existing.isMonitored && isStillMember) + : (isKnownSupplierGroup && isStillMember); + + return { + id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`, + jid, + name, + isMonitored: isStillMember ? isMonitored : false, + isStillMember, + hasLeft: !isStillMember, + supplierId: existing?.supplierId, + supplierName: existing?.supplierName || name, + profitMarginOverride: existing?.profitMarginOverride || 35, + autoPublish: existing?.autoPublish ?? true, + participantsCount: participants ?? lg.unreadCount, + notes: isStillMember ? (existing?.notes || '') : 'Saliste de este grupo en WhatsApp', + }; + }) + ); // Asegurar que los locales sigan presentes localGroups.forEach((lg) => { @@ -70,6 +99,9 @@ export async function GET(request: Request) { } }); + // Guardar estado actualizado en dataService + dataService.saveMonitoredGroups(merged); + return NextResponse.json({ success: true, isLive: true, @@ -95,9 +127,9 @@ export async function POST(request: Request) { try { const body = await request.json(); - if (body.action === 'toggle') { + if (body.action === 'toggle' || body.action === 'deactivate-group') { const { idOrJid, isMonitored } = body; - const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored); + const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored !== undefined ? isMonitored : false); return NextResponse.json({ success: true, group: updated }); } diff --git a/zapply-app/src/types/index.ts b/zapply-app/src/types/index.ts index 5432358..4349d30 100644 --- a/zapply-app/src/types/index.ts +++ b/zapply-app/src/types/index.ts @@ -209,5 +209,7 @@ export interface MonitoredWhatsAppGroup { autoPublish: boolean; participantsCount?: number; lastMessageAt?: string; + isStillMember?: boolean; + hasLeft?: boolean; notes?: string; }