Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/api/src/services/__tests__/persons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof mock> }).execute = mock(async () => [participantBackedPerson]);

const result = await service.search('Cadu Cassau', 5);

expect((mockDb as unknown as { execute: ReturnType<typeof mock> }).execute).toHaveBeenCalled();
expect(result).toEqual([participantBackedPerson]);
});
});

describe('getIdentityForChannel()', () => {
test('returns identity when person has single identity on channel', async () => {
const identity = createMockIdentity({
Expand Down
78 changes: 66 additions & 12 deletions packages/api/src/services/persons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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<Person[]> {
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[];
}

/**
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/commands/persons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' });
Expand Down
Loading