From 33b6b5d26f08ed1e7ca893759944846577797ce5 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Thu, 15 Oct 2026 03:39:09 +0530 Subject: [PATCH] template --- backend/app/routes_whatsapp.py | 5 +- backend/app/whatsapp_onboarding.py | 73 ++++++++++- .../whatsapp/CustomerWhatsAppInbox.tsx | 123 ++++++++++++------ frontend/src/lib/api.ts | 8 +- 4 files changed, 159 insertions(+), 50 deletions(-) diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 87da5a6..0c339c4 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -303,11 +303,12 @@ async def whatsapp_account_refresh( @router.get("/accounts/{account_id}/messages") def whatsapp_account_messages( account_id: str, - limit: int = Query(default=300, ge=1, le=500), + limit: int = Query(default=500, ge=1, le=500), + q: str = Query(default="", max_length=80), db: Session = Depends(get_db), user: UserAccount = Depends(current_user), ) -> list[dict]: - return list_account_messages(db, user, account_id, limit) + return list_account_messages(db, user, account_id, limit, q) @router.post("/accounts/{account_id}/delivery") diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index 77cf5ee..73c1e65 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -16,7 +16,7 @@ from typing import Any import httpx from fastapi import HTTPException, status -from sqlalchemy import or_, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session, selectinload from .config import settings @@ -1182,16 +1182,81 @@ def customer_token_for_phone(db: Session, user: UserAccount, phone_number_id: st return token, phone.phone_number_id -def list_account_messages(db: Session, user: UserAccount, account_id: Any, limit: int = 300) -> list[dict[str, Any]]: +def _message_peer(): + return case( + (WhatsAppMessage.direction == "inbound", WhatsAppMessage.from_number), + else_=WhatsAppMessage.to_number, + ) + + +def _like_term(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +def list_account_messages( + db: Session, + user: UserAccount, + account_id: Any, + limit: int = 500, + query: str | None = None, +) -> list[dict[str, Any]]: account = _owned_account(db, user, account_id) phone_ids = [phone.phone_number_id for phone in account.phones] if not phone_ids: return [] + peer = _message_peer() + scoped = ( + WhatsAppMessage.user_id == user.id, + WhatsAppMessage.phone_number_id.in_(phone_ids), + WhatsAppMessage.message_type != "status", + ) + term = (query or "").strip() + if term: + pattern = _like_term(term) + matched = [ + WhatsAppMessage.body.ilike(pattern, escape="\\"), + WhatsAppMessage.contact_name.ilike(pattern, escape="\\"), + WhatsAppMessage.from_number.ilike(pattern, escape="\\"), + WhatsAppMessage.to_number.ilike(pattern, escape="\\"), + ] + digits = re.sub(r"\D", "", term) + if len(digits) >= 4: + matched.append(WhatsAppMessage.from_number.ilike(f"%{digits}%")) + matched.append(WhatsAppMessage.to_number.ilike(f"%{digits}%")) + peers = [ + item + for item in db.scalars(select(peer).where(*scoped, or_(*matched)).distinct()).all() + if item + ] + if not peers: + return [] + rows = db.scalars( + select(WhatsAppMessage) + .where(*scoped, peer.in_(peers)) + .order_by(WhatsAppMessage.created_at.desc()) + .limit(5000) + ).all() + return [serialize_message(row) for row in rows] + + recent_peers = [ + item + for item in db.scalars( + select(peer) + .where(*scoped) + .group_by(peer) + .order_by(func.max(WhatsAppMessage.created_at).desc()) + .limit(min(max(limit, 1), 500)) + ).all() + if item + ] + if not recent_peers: + return [] rows = db.scalars( select(WhatsAppMessage) - .where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids)) + .where(*scoped, peer.in_(recent_peers)) .order_by(WhatsAppMessage.created_at.desc()) - .limit(min(limit, 500)) + .limit(5000) ).all() return [serialize_message(row) for row in rows] diff --git a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx index eb9cab1..aafe3f9 100644 --- a/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx +++ b/frontend/src/features/whatsapp/CustomerWhatsAppInbox.tsx @@ -77,6 +77,36 @@ function initials(name: string) { return name.replace(/\D/g, '').slice(-2) || 'WA' } +function buildThreads(items: CustomerWhatsAppMessage[], readPeers: Set, starred: Set) { + const grouped = new Map() + for (const message of items) { + if (message.message_type === 'status') continue + const peer = message.direction === 'inbound' ? message.from : message.to + if (!peer) continue + const list = grouped.get(peer) ?? [] + list.push(message) + grouped.set(peer, list) + } + return [...grouped.entries()] + .map(([peer, thread]) => { + const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) + const latest = sorted[sorted.length - 1] + const name = sorted.find((item) => item.contact_name)?.contact_name + const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length + return { + peer, + title: name || formatWaNumber(peer), + phoneLabel: formatWaNumber(peer), + initials: initials(name || peer), + messages: sorted, + latest, + unread, + important: starred.has(peer), + } + }) + .sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at)) +} + export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) { const [messages, setMessages] = useState([]) const [templates, setTemplates] = useState([]) @@ -101,6 +131,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc const [buttonTwo, setButtonTwo] = useState('') const [buttonThree, setButtonThree] = useState('') const [threadQuery, setThreadQuery] = useState('') + const [searchHits, setSearchHits] = useState(null) const [listFilter, setListFilter] = useState('all') const [readPeers, setReadPeers] = useState>(new Set()) const [starred, setStarred] = useState>(new Set()) @@ -132,66 +163,74 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc } async function loadMessages() { - setMessages(await whatsappApi.messages(account.id)) + setMessages(await whatsappApi.messages(account.id, { limit: 500 })) } useEffect(() => { + let cancelled = false Promise.all([ - whatsappApi.messages(account.id), + whatsappApi.messages(account.id, { limit: 500 }), account.status === 'disconnected' ? Promise.resolve({ available: false, templates: [] as WhatsAppTemplateRow[] }) : whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })), ]) .then(([items, templatePayload]) => { + if (cancelled) return setMessages(items) setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status)) }) - .catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations')) - .finally(() => setLoading(false)) - const timer = window.setInterval(() => { - loadMessages().catch(() => undefined) - }, 8000) - return () => window.clearInterval(timer) - }, [account.id]) - - const threads = useMemo(() => { - const grouped = new Map() - for (const message of messages) { - if (message.message_type === 'status') continue - const peer = message.direction === 'inbound' ? message.from : message.to - if (!peer) continue - const list = grouped.get(peer) ?? [] - list.push(message) - grouped.set(peer, list) - } - return [...grouped.entries()] - .map(([peer, thread]) => { - const sorted = [...thread].sort((a, b) => a.created_at.localeCompare(b.created_at)) - const latest = sorted[sorted.length - 1] - const name = sorted.find((item) => item.contact_name)?.contact_name - const unread = readPeers.has(peer) ? 0 : sorted.filter((item) => item.direction === 'inbound' && item.status !== 'read').length - return { - peer, - title: name || formatWaNumber(peer), - phoneLabel: formatWaNumber(peer), - initials: initials(name || peer), - messages: sorted, - latest, - unread, - important: starred.has(peer), - } + .catch((reason) => { + if (!cancelled) setError(reason instanceof Error ? reason.message : 'Unable to load conversations') }) - .sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at)) - }, [messages, readPeers, starred]) + .finally(() => { + if (!cancelled) setLoading(false) + }) + const timer = window.setInterval(() => { + whatsappApi.messages(account.id, { limit: 500 }).then((items) => { + if (!cancelled) setMessages(items) + }).catch(() => undefined) + }, 8000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [account.id, account.status]) + + useEffect(() => { + const query = threadQuery.trim() + if (!query) { + setSearchHits(null) + return + } + let cancelled = false + const timer = window.setTimeout(() => { + whatsappApi.messages(account.id, { q: query }) + .then((items) => { + if (!cancelled) setSearchHits(items) + }) + .catch(() => { + if (!cancelled) setSearchHits([]) + }) + }, 300) + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [account.id, threadQuery]) + + const inboxThreads = useMemo(() => buildThreads(messages, readPeers, starred), [messages, readPeers, starred]) + const searchThreads = useMemo( + () => (threadQuery.trim() && searchHits ? buildThreads(searchHits, readPeers, starred) : null), + [searchHits, readPeers, starred, threadQuery], + ) + const threads = searchThreads ?? inboxThreads const visibleThreads = threads.filter((thread) => { - const query = threadQuery.trim().toLowerCase() - if (query && !`${thread.title} ${thread.phoneLabel} ${thread.latest.body || ''}`.toLowerCase().includes(query)) return false if (listFilter === 'unread') return thread.unread > 0 if (listFilter === 'important') return thread.important return true }) - const active = threads.find((thread) => thread.peer === selectedPeer) || null + const active = threads.find((thread) => thread.peer === selectedPeer) || inboxThreads.find((thread) => thread.peer === selectedPeer) || null const activeThread = active?.messages ?? [] const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0) const importantTotal = threads.filter((thread) => thread.important).length @@ -322,7 +361,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
- {visibleThreads.length === 0 &&

No conversations yet.

} + {visibleThreads.length === 0 &&

{threadQuery.trim() ? 'No conversations match this search.' : 'No conversations yet.'}

} {visibleThreads.map((thread) => (