This commit is contained in:
debu-2 2026-10-15 03:39:09 +05:30
parent 9f03e5df32
commit 33b6b5d26f
4 changed files with 159 additions and 50 deletions

View file

@ -303,11 +303,12 @@ async def whatsapp_account_refresh(
@router.get("/accounts/{account_id}/messages") @router.get("/accounts/{account_id}/messages")
def whatsapp_account_messages( def whatsapp_account_messages(
account_id: str, 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), db: Session = Depends(get_db),
user: UserAccount = Depends(current_user), user: UserAccount = Depends(current_user),
) -> list[dict]: ) -> 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") @router.post("/accounts/{account_id}/delivery")

View file

@ -16,7 +16,7 @@ from typing import Any
import httpx import httpx
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import or_, select from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from .config import settings 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 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) account = _owned_account(db, user, account_id)
phone_ids = [phone.phone_number_id for phone in account.phones] phone_ids = [phone.phone_number_id for phone in account.phones]
if not phone_ids: if not phone_ids:
return [] 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( rows = db.scalars(
select(WhatsAppMessage) 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()) .order_by(WhatsAppMessage.created_at.desc())
.limit(min(limit, 500)) .limit(5000)
).all() ).all()
return [serialize_message(row) for row in rows] return [serialize_message(row) for row in rows]

View file

@ -77,6 +77,36 @@ function initials(name: string) {
return name.replace(/\D/g, '').slice(-2) || 'WA' return name.replace(/\D/g, '').slice(-2) || 'WA'
} }
function buildThreads(items: CustomerWhatsAppMessage[], readPeers: Set<string>, starred: Set<string>) {
const grouped = new Map<string, CustomerWhatsAppMessage[]>()
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 }) { export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAccount; onBack: () => void }) {
const [messages, setMessages] = useState<CustomerWhatsAppMessage[]>([]) const [messages, setMessages] = useState<CustomerWhatsAppMessage[]>([])
const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([]) const [templates, setTemplates] = useState<WhatsAppTemplateRow[]>([])
@ -101,6 +131,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
const [buttonTwo, setButtonTwo] = useState('') const [buttonTwo, setButtonTwo] = useState('')
const [buttonThree, setButtonThree] = useState('') const [buttonThree, setButtonThree] = useState('')
const [threadQuery, setThreadQuery] = useState('') const [threadQuery, setThreadQuery] = useState('')
const [searchHits, setSearchHits] = useState<CustomerWhatsAppMessage[] | null>(null)
const [listFilter, setListFilter] = useState<ListFilter>('all') const [listFilter, setListFilter] = useState<ListFilter>('all')
const [readPeers, setReadPeers] = useState<Set<string>>(new Set()) const [readPeers, setReadPeers] = useState<Set<string>>(new Set())
const [starred, setStarred] = useState<Set<string>>(new Set()) const [starred, setStarred] = useState<Set<string>>(new Set())
@ -132,66 +163,74 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
} }
async function loadMessages() { async function loadMessages() {
setMessages(await whatsappApi.messages(account.id)) setMessages(await whatsappApi.messages(account.id, { limit: 500 }))
} }
useEffect(() => { useEffect(() => {
let cancelled = false
Promise.all([ Promise.all([
whatsappApi.messages(account.id), whatsappApi.messages(account.id, { limit: 500 }),
account.status === 'disconnected' account.status === 'disconnected'
? Promise.resolve({ available: false, templates: [] as WhatsAppTemplateRow[] }) ? Promise.resolve({ available: false, templates: [] as WhatsAppTemplateRow[] })
: whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })), : whatsappApi.templates(account.id).catch(() => ({ available: false, templates: [] as WhatsAppTemplateRow[] })),
]) ])
.then(([items, templatePayload]) => { .then(([items, templatePayload]) => {
if (cancelled) return
setMessages(items) setMessages(items)
setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status)) setTemplates(templatePayload.templates.filter((item) => item.status === 'APPROVED' || !item.status))
}) })
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load conversations')) .catch((reason) => {
.finally(() => setLoading(false)) if (!cancelled) setError(reason instanceof Error ? reason.message : 'Unable to load conversations')
const timer = window.setInterval(() => {
loadMessages().catch(() => undefined)
}, 8000)
return () => window.clearInterval(timer)
}, [account.id])
const threads = useMemo(() => {
const grouped = new Map<string, CustomerWhatsAppMessage[]>()
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),
}
}) })
.sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at)) .finally(() => {
}, [messages, readPeers, starred]) 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 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 === 'unread') return thread.unread > 0
if (listFilter === 'important') return thread.important if (listFilter === 'important') return thread.important
return true 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 activeThread = active?.messages ?? []
const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0) const unreadTotal = threads.reduce((sum, thread) => sum + thread.unread, 0)
const importantTotal = threads.filter((thread) => thread.important).length const importantTotal = threads.filter((thread) => thread.important).length
@ -322,7 +361,7 @@ export function CustomerWhatsAppInbox({ account, onBack }: { account: WhatsAppAc
<button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button> <button type="button" className={listFilter === 'important' ? 'active' : ''} onClick={() => setListFilter('important')}>Important <em>{importantTotal}</em></button>
</div> </div>
<div className="wa-thread-list"> <div className="wa-thread-list">
{visibleThreads.length === 0 && <p className="wa-empty">No conversations yet.</p>} {visibleThreads.length === 0 && <p className="wa-empty">{threadQuery.trim() ? 'No conversations match this search.' : 'No conversations yet.'}</p>}
{visibleThreads.map((thread) => ( {visibleThreads.map((thread) => (
<button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}> <button key={thread.peer} type="button" className={`wa-thread ${selectedPeer === thread.peer ? 'active' : ''}`} onClick={() => openThread(thread.peer)}>
<span className="wa-avatar wa-avatar-green">{thread.initials}</span> <span className="wa-avatar wa-avatar-green">{thread.initials}</span>

View file

@ -1295,8 +1295,12 @@ export const whatsappApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ app_secret: appSecret }), body: JSON.stringify({ app_secret: appSecret }),
}), }),
messages: (accountId: string) => messages: (accountId: string, options?: { limit?: number; q?: string }) => {
jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages`), const params = new URLSearchParams()
params.set('limit', String(options?.limit ?? 500))
if (options?.q) params.set('q', options.q)
return jsonRequest<CustomerWhatsAppMessage[]>(`/api/v1/whatsapp/accounts/${accountId}/messages?${params}`)
},
sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) => sendAccountMessage: (accountId: string, payload: CustomerWhatsAppSend) =>
jsonRequest<CustomerWhatsAppMessage>(`/api/v1/whatsapp/accounts/${accountId}/messages/send`, { jsonRequest<CustomerWhatsAppMessage>(`/api/v1/whatsapp/accounts/${accountId}/messages/send`, {
method: 'POST', method: 'POST',