diff --git a/server/src/index.js b/server/src/index.js index 699aa7f..57cbe2d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -33,6 +33,7 @@ import { isTimestampOlderThan, webhookEventClaimDecision } from './reliability.j import { canonicalWhatsAppJid, isWeakWhatsAppDisplayName, + resolvedWhatsAppJid, reusableWhatsAppConversations, whatsappConversationActivityAt, whatsappCustomerStableId, @@ -3231,7 +3232,9 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} if (!rawJid || !rawMessage.id) return null const aliasCacheKey = `${channel.gatewayAccountId}:${rawJid}` const cachedJid = whatsappCanonicalJidCache.get(aliasCacheKey) - const preliminaryJid = cachedJid || rawJid + const rawCanonicalJid = canonicalWhatsAppJid(rawJid) + const hasCanonicalAlias = Boolean(cachedJid && cachedJid !== rawCanonicalJid) + const preliminaryJid = hasCanonicalAlias ? cachedJid : rawCanonicalJid const rawConversationId = `${channel.id}:${rawJid}` const preliminaryConversationId = `${channel.id}:${preliminaryJid}` const preliminaryCustomer = await resolveWhatsAppCustomer( @@ -3242,7 +3245,7 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} ) const needsIdentity = Boolean( options.identity - || (rawJid.endsWith('@lid') && !cachedJid) + || (rawJid.endsWith('@lid') && !hasCanonicalAlias) || !preliminaryCustomer || isWeakWhatsAppDisplayName(preliminaryCustomer?.name, rawJid), ) @@ -3256,8 +3259,15 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} avatarUrl: preliminaryCustomer.avatarUrl, chatType: preliminaryCustomer.whatsappChatType, }) - const jid = cachedJid || canonicalWhatsAppJid(rawJid, identity || {}) - whatsappCanonicalJidCache.set(aliasCacheKey, jid) + const jid = resolvedWhatsAppJid(rawJid, identity || {}, cachedJid) + // A LID is a temporary alias, not a stable customer identity. Never cache an + // unresolved LID as canonical; otherwise a later sync can recreate a merged + // duplicate conversation even after the gateway learns its phone JID. + if (jid !== rawCanonicalJid || !rawJid.endsWith('@lid')) { + whatsappCanonicalJidCache.set(aliasCacheKey, jid) + } else { + whatsappCanonicalJidCache.delete(aliasCacheKey) + } const conversationId = `${channel.id}:${jid}` if ( await getResource(tenantId, 'conversation-tombstones', conversationId) @@ -3478,6 +3488,11 @@ async function mergeLegacyWhatsAppConversations(tenantId, channel, jid, aliasJid await deleteResource(tenantId, `messages:${source.id}`, message.id) } await deleteResource(tenantId, 'conversations', source.id) + broadcast(tenantId, { + type: 'conversation.deleted', + conversationId: source.id, + mergedIntoConversationId: targetId, + }) } await patchResource(tenantId, 'conversations', targetId, { customerId, diff --git a/server/src/whatsapp-identity.js b/server/src/whatsapp-identity.js index 21f5935..e7beebe 100644 --- a/server/src/whatsapp-identity.js +++ b/server/src/whatsapp-identity.js @@ -13,6 +13,15 @@ export function canonicalWhatsAppJid(jid, identity = {}) { return phone ? `${phone}@s.whatsapp.net` : raw } +export function resolvedWhatsAppJid(jid, identity = {}, cachedJid = '') { + const fallback = canonicalWhatsAppJid(jid) + const resolved = canonicalWhatsAppJid(jid, identity) + if (resolved && resolved !== fallback) return resolved + + const cached = canonicalWhatsAppJid(cachedJid) + return cached && cached !== fallback ? cached : fallback +} + export function whatsappCustomerStableId(jid) { const normalized = canonicalWhatsAppJid(jid) const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24) diff --git a/server/test/whatsapp-identity.test.js b/server/test/whatsapp-identity.test.js index fe56857..1917a0c 100644 --- a/server/test/whatsapp-identity.test.js +++ b/server/test/whatsapp-identity.test.js @@ -3,6 +3,7 @@ import test from 'node:test' import { canonicalWhatsAppJid, isWeakWhatsAppDisplayName, + resolvedWhatsAppJid, reusableWhatsAppConversations, whatsappCustomerStableId, } from '../src/whatsapp-identity.js' @@ -16,6 +17,14 @@ test('WhatsApp LID resolves to the same canonical phone identity', () => { assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: '15551234567:0@s.whatsapp.net' }), phoneJid) }) +test('a newly resolved phone identity overrides a stale cached LID', () => { + const lid = '987654321@lid' + const phoneJid = '15551234567@s.whatsapp.net' + assert.equal(resolvedWhatsAppJid(lid, {}, lid), lid) + assert.equal(resolvedWhatsAppJid(lid, { phone: '+1 555 123 4567' }, lid), phoneJid) + assert.equal(resolvedWhatsAppJid(lid, {}, phoneJid), phoneJid) +}) + test('WhatsApp customer id remains stable after gateway reauthorization', () => { assert.equal( whatsappCustomerStableId('15551234567@s.whatsapp.net'), diff --git a/src/pages/inbox/InboxPage.tsx b/src/pages/inbox/InboxPage.tsx index 35353dc..fd404d2 100644 --- a/src/pages/inbox/InboxPage.tsx +++ b/src/pages/inbox/InboxPage.tsx @@ -300,6 +300,27 @@ export function InboxPage() { backfill?: import('@/types').AiBackfillStatus } try { event = JSON.parse(String(raw.data)) } catch { return } + if (event.type === 'conversation.deleted' && event.conversationId) { + queryClient.removeQueries({ queryKey: ['conversation', event.conversationId] }) + queryClient.removeQueries({ queryKey: ['messages', event.conversationId] }) + queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>( + { queryKey: ['conversations'] }, + (current) => current + ? { + ...current, + list: current.list.filter((item) => item.id !== event.conversationId), + total: Math.max(0, current.total - (current.list.some((item) => item.id === event.conversationId) ? 1 : 0)), + } + : current, + ) + if (convId === event.conversationId) { + setSearchParams((prev) => { + prev.delete('c') + return prev + }) + } + return + } const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>( activeConversationQueryKeyRef.current, ) @@ -396,7 +417,7 @@ export function InboxPage() { if (reconnectTimer) clearTimeout(reconnectTimer) socket?.close() } - }, [token, queryClient]) + }, [token, queryClient, convId, setSearchParams]) const setConv = useCallback( (id: string) => {