template
This commit is contained in:
parent
9f03e5df32
commit
33b6b5d26f
4 changed files with 159 additions and 50 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
rows = db.scalars(
|
||||||
select(WhatsAppMessage)
|
select(WhatsAppMessage)
|
||||||
.where(WhatsAppMessage.user_id == user.id, WhatsAppMessage.phone_number_id.in_(phone_ids))
|
.where(*scoped, peer.in_(peers))
|
||||||
.order_by(WhatsAppMessage.created_at.desc())
|
.order_by(WhatsAppMessage.created_at.desc())
|
||||||
.limit(min(limit, 500))
|
.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(*scoped, peer.in_(recent_peers))
|
||||||
|
.order_by(WhatsAppMessage.created_at.desc())
|
||||||
|
.limit(5000)
|
||||||
).all()
|
).all()
|
||||||
return [serialize_message(row) for row in rows]
|
return [serialize_message(row) for row in rows]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue