From 8c95674284729607243f943e13f80a39e521103a Mon Sep 17 00:00:00 2001 From: Felipe Rosa Date: Tue, 2 Jun 2026 06:59:41 -0300 Subject: [PATCH] fix(persons): search WhatsApp participant identities --- .../src/services/__tests__/persons.test.ts | 22 ++++++ packages/api/src/services/persons.ts | 78 ++++++++++++++++--- packages/cli/src/commands/persons.ts | 10 ++- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/api/src/services/__tests__/persons.test.ts b/packages/api/src/services/__tests__/persons.test.ts index 54b4d0a67..dce2d8007 100644 --- a/packages/api/src/services/__tests__/persons.test.ts +++ b/packages/api/src/services/__tests__/persons.test.ts @@ -88,6 +88,28 @@ describe('PersonService', () => { service = new PersonService(mockDb as unknown as Database, mockEventBus); }); + describe('search()', () => { + test('returns people whose WhatsApp name only exists on chat participants', async () => { + const participantBackedPerson = { + id: 'person-cadu', + displayName: 'Cadu Cassau', + primaryPhone: null, + primaryEmail: null, + avatarUrl: null, + metadata: null, + createdAt: new Date('2026-04-10T06:36:32Z'), + updatedAt: new Date('2026-06-02T09:36:18Z'), + }; + + (mockDb as unknown as { execute: ReturnType }).execute = mock(async () => [participantBackedPerson]); + + const result = await service.search('Cadu Cassau', 5); + + expect((mockDb as unknown as { execute: ReturnType }).execute).toHaveBeenCalled(); + expect(result).toEqual([participantBackedPerson]); + }); + }); + describe('getIdentityForChannel()', () => { test('returns identity when person has single identity on channel', async () => { const identity = createMockIdentity({ diff --git a/packages/api/src/services/persons.ts b/packages/api/src/services/persons.ts index c1de264d6..2b5df3a0f 100644 --- a/packages/api/src/services/persons.ts +++ b/packages/api/src/services/persons.ts @@ -15,7 +15,7 @@ import { persons, platformIdentities, } from '@omni/db'; -import { and, desc, eq, ilike, isNotNull, ne, or, sql } from 'drizzle-orm'; +import { and, desc, eq, isNotNull, ne, or, sql } from 'drizzle-orm'; export interface PersonWithIdentities extends Person { identities: PlatformIdentity[]; @@ -76,22 +76,76 @@ export class PersonService { } /** - * Search persons by name, email, or phone + * Search persons by name, email, phone, platform identity, or chat participant display name. + * + * WhatsApp LID DMs frequently have the useful contact name only on chat_participants, + * while the canonical person row is intentionally sparse. Searching persons alone makes + * the identity graph useless for the main operational task: "find my chat with X". */ async search(query: string, limit = 20): Promise { const searchPattern = `%${query}%`; - return this.db - .select() - .from(persons) - .where( - or( - ilike(persons.displayName, searchPattern), - ilike(persons.primaryEmail, searchPattern), - ilike(persons.primaryPhone, searchPattern), - ), + const result = await this.db.execute(sql` + WITH candidates AS ( + SELECT + p.id, + CASE + WHEN p.display_name ILIKE ${searchPattern} ESCAPE '' THEN p.display_name + WHEN pi.platform_username ILIKE ${searchPattern} ESCAPE '' THEN pi.platform_username + WHEN cp.display_name ILIKE ${searchPattern} ESCAPE '' THEN cp.display_name + ELSE COALESCE(NULLIF(p.display_name, ''), NULLIF(pi.platform_username, ''), NULLIF(cp.display_name, '')) + END AS "displayName", + p.primary_phone AS "primaryPhone", + p.primary_email AS "primaryEmail", + p.avatar_url AS "avatarUrl", + p.metadata, + p.created_at AS "createdAt", + p.updated_at AS "updatedAt", + GREATEST( + COALESCE(pi.last_seen_at, 'epoch'::timestamptz), + COALESCE(cp.last_seen_at, 'epoch'::timestamptz), + COALESCE(p.updated_at, 'epoch'::timestamptz) + ) AS rank_ts + FROM persons p + LEFT JOIN platform_identities pi ON pi.person_id = p.id + LEFT JOIN chat_participants cp ON cp.person_id = p.id OR cp.platform_identity_id = pi.id + WHERE + p.display_name ILIKE ${searchPattern} ESCAPE '' + OR p.primary_email ILIKE ${searchPattern} ESCAPE '' + OR p.primary_phone ILIKE ${searchPattern} ESCAPE '' + OR pi.platform_username ILIKE ${searchPattern} ESCAPE '' + OR pi.platform_user_id ILIKE ${searchPattern} ESCAPE '' + OR cp.display_name ILIKE ${searchPattern} ESCAPE '' + OR cp.platform_user_id ILIKE ${searchPattern} ESCAPE '' + ), distinct_candidates AS ( + SELECT DISTINCT ON (id) + id, + "displayName", + "primaryPhone", + "primaryEmail", + "avatarUrl", + metadata, + "createdAt", + "updatedAt", + rank_ts + FROM candidates + ORDER BY id, rank_ts DESC ) - .limit(limit); + SELECT + id, + "displayName", + "primaryPhone", + "primaryEmail", + "avatarUrl", + metadata, + "createdAt", + "updatedAt" + FROM distinct_candidates + ORDER BY rank_ts DESC + LIMIT ${limit} + `); + + return result as unknown as Person[]; } /** diff --git a/packages/cli/src/commands/persons.ts b/packages/cli/src/commands/persons.ts index 0527958c0..8be780512 100644 --- a/packages/cli/src/commands/persons.ts +++ b/packages/cli/src/commands/persons.ts @@ -36,14 +36,16 @@ export function createPersonsCommand(): Command { const persons = results as Array<{ id: string; displayName: string | null; - email: string | null; - phone: string | null; + primaryEmail?: string | null; + primaryPhone?: string | null; + email?: string | null; + phone?: string | null; }>; const items = persons.map((p) => ({ id: p.id, displayName: p.displayName ?? '-', - email: p.email ?? '-', - phone: p.phone ?? '-', + email: p.primaryEmail ?? p.email ?? '-', + phone: p.primaryPhone ?? p.phone ?? '-', })); output.list(items, { emptyMessage: 'No persons found.' });