From 2920e2ab31be8339513ab0c1e74e0a373b04bd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 02:50:41 +0000 Subject: [PATCH] Add Correspondence side-panel section to email and calendar Adds a "Correspondence" bento at the bottom of the email block's and the calendar's right-hand side panels. It surfaces, for the external parties on the open thread / selected event: 1. their CRM contact records (clickable through to the contact panel), 2. the CRM company record behind each external domain, and 3. the 50 most recent email threads shared with them, loaded async and scrolled inside a fixed-height box so the bento can't stretch. The section renders only when the thread/event actually has external parties, where "external" means an address that is neither the signed-in user's nor on their email domain. On the calendar that additionally means only while an event is selected. CRM lookup goes domain -> company id via the unified-search CRM source (the CRM service has no domain lookup endpoint), then id -> company + contacts via the existing company endpoint. Search matches domains by substring, so a hit is accepted only on an exact domain equality check. Rows fall back to what the thread/event knows about a party when the team tracks no CRM record for them. The thread list uses the raw any-direction `ef` address filter rather than the CRM-scoped `eca` widener, so it stays in the caller's own mailbox scope and works whether or not CRM is enabled for those correspondents. --- .../sidepanel/EmailSidePanelSections.tsx | 36 ++- .../calendar/CalendarSidePanelSections.tsx | 43 ++++ .../features/companies/Company/emailFilter.ts | 12 + .../correspondence/CorrespondenceSection.tsx | 212 ++++++++++++++++++ apps/web/src/features/correspondence/index.ts | 7 + .../features/correspondence/parties.test.ts | 85 +++++++ .../src/features/correspondence/parties.ts | 76 +++++++ .../use-correspondence-threads.ts | 42 ++++ .../correspondence/use-crm-records.ts | 45 ++++ .../src/lib/queries/crm/company-for-domain.ts | 100 +++++++++ apps/web/src/lib/queries/crm/keys.ts | 1 + 11 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/correspondence/CorrespondenceSection.tsx create mode 100644 apps/web/src/features/correspondence/index.ts create mode 100644 apps/web/src/features/correspondence/parties.test.ts create mode 100644 apps/web/src/features/correspondence/parties.ts create mode 100644 apps/web/src/features/correspondence/use-correspondence-threads.ts create mode 100644 apps/web/src/features/correspondence/use-crm-records.ts create mode 100644 apps/web/src/lib/queries/crm/company-for-domain.ts diff --git a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx index a9670f71865..398de61f969 100644 --- a/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx +++ b/apps/web/src/features/block-email/component/sidepanel/EmailSidePanelSections.tsx @@ -1,12 +1,18 @@ +import { + type CorrespondenceParty, + CorrespondenceSidePanelSection, + externalParties, +} from '@app/features/correspondence'; import { EntityPropertiesSection, EntityTagsSection, } from '@app/features/property/side-panel/properties'; import { SidePanel } from '@components/app/side-panel'; import { References } from '@core/component/References'; +import { useEmail } from '@core/context/user'; import { useAttachmentReferencesQuery } from '@queries/storage/attachment-references'; import type { ItemType } from '@service-storage/client'; -import { Show, Suspense } from 'solid-js'; +import { createMemo, Show, Suspense } from 'solid-js'; import { useEmailContext } from '../EmailContext'; interface EmailSidePanelSectionsProps { @@ -17,6 +23,30 @@ interface EmailSidePanelSectionsProps { export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) { const emailCtx = useEmailContext(); const canEdit = () => emailCtx.permissions().isOwner; + const currentUserEmail = useEmail(); + + // Everyone visibly on the chain — senders and To/Cc recipients across every + // message. Bcc is left out: it is deliberately hidden correspondence and + // doesn't belong in a "who is on this thread" summary. + const externalThreadParties = createMemo(() => { + const messages = emailCtx.thread()?.messages ?? []; + const participants: CorrespondenceParty[] = []; + for (const message of messages) { + if (message.from?.email) { + participants.push({ + email: message.from.email, + name: message.from.name ?? undefined, + }); + } + for (const contact of [...message.to, ...message.cc]) { + participants.push({ + email: contact.email, + name: contact.name ?? undefined, + }); + } + } + return externalParties(participants, currentUserEmail()); + }); return ( <> @@ -44,6 +74,10 @@ export function EmailSidePanelSections(props: EmailSidePanelSectionsProps) { + ); } diff --git a/apps/web/src/features/calendar/CalendarSidePanelSections.tsx b/apps/web/src/features/calendar/CalendarSidePanelSections.tsx index 7c9b9eba34a..858a8cab2f6 100644 --- a/apps/web/src/features/calendar/CalendarSidePanelSections.tsx +++ b/apps/web/src/features/calendar/CalendarSidePanelSections.tsx @@ -1,4 +1,10 @@ +import { + type CorrespondenceParty, + CorrespondenceSidePanelSection, + externalParties, +} from '@app/features/correspondence'; import { SidePanel, useSidePanel } from '@components/app/side-panel/SidePanel'; +import { useEmail } from '@core/context/user'; import CloseIcon from '@phosphor/x.svg'; import { Button, Calendar as MiniCalendar } from '@ui'; import { createEffect, createMemo, createSignal, on, Show } from 'solid-js'; @@ -137,6 +143,42 @@ function CalendarSourcesSidePanelSection() { ); } +/** + * Correspondence for the selected event. Only present while an event is + * selected, and (via the section itself) only when that event has external + * parties on it. + */ +function CalendarCorrespondenceSidePanelSection() { + const calendarView = useCalendarView(); + const currentUserEmail = useEmail(); + + // The organizer plus every attendee. `isSelf` marks the connected account + // on the event; `externalParties` drops it again by address, so a + // provider that omits the flag still can't leak the user into the panel. + const parties = createMemo(() => { + const event = calendarView.selectedEvent(); + if (!event) return []; + + const participants: CorrespondenceParty[] = []; + if (event.organizerEmail) { + participants.push({ + email: event.organizerEmail, + name: event.organizerName, + }); + } + for (const attendee of event.attendees) { + if (attendee.isSelf) continue; + participants.push({ + email: attendee.email, + name: attendee.displayName ?? undefined, + }); + } + return externalParties(participants, currentUserEmail()); + }); + + return ; +} + /** Registers the calendar's contextual right-side panel sections. */ export function CalendarSidePanelSections() { const sidePanel = useSidePanel(); @@ -146,6 +188,7 @@ export function CalendarSidePanelSections() { + ); } diff --git a/apps/web/src/features/companies/Company/emailFilter.ts b/apps/web/src/features/companies/Company/emailFilter.ts index a8f715180ea..e99e3da5a73 100644 --- a/apps/web/src/features/companies/Company/emailFilter.ts +++ b/apps/web/src/features/companies/Company/emailFilter.ts @@ -35,6 +35,18 @@ export function emailFilterForAddress(email: string): unknown { return anyDirection({ Complete: email }); } +/** + * Any-direction match for any of the given addresses. Returns `undefined` + * when the list is empty so the caller can skip setting `ef` entirely. + */ +export function emailFilterForAddresses( + addresses: string[] +): unknown | undefined { + if (addresses.length === 0) return undefined; + const trees = addresses.map((address) => emailFilterForAddress(address)); + return trees.reduce((acc, cur) => ({ '|': [acc, cur] })); +} + /** * Any-direction match for any of the given domains. Returns `undefined` * when the list is empty so the caller can skip setting `ef` entirely. diff --git a/apps/web/src/features/correspondence/CorrespondenceSection.tsx b/apps/web/src/features/correspondence/CorrespondenceSection.tsx new file mode 100644 index 00000000000..c416827dbb0 --- /dev/null +++ b/apps/web/src/features/correspondence/CorrespondenceSection.tsx @@ -0,0 +1,212 @@ +import { openEntityInSplitFromUnifiedList } from '@app/features/next-soup/utils'; +import { SidePanel } from '@components/app/side-panel'; +import { useSplitLayout } from '@components/app/split-layout/layout'; +import { + ListEntity, + ListEntityMetadataQueryProvider, + ListLayoutProvider, +} from '@entity'; +import { + createMemo, + createSignal, + For, + type JSX, + type ParentProps, + Show, + Suspense, +} from 'solid-js'; +import { type CorrespondenceParty, partyDomains } from './parties'; +import { + CORRESPONDENCE_THREAD_LIMIT, + useCorrespondenceThreadsQuery, +} from './use-correspondence-threads'; +import { + useCrmCompanyForDomain, + useCrmContactForAddress, +} from './use-crm-records'; + +/** + * The "Correspondence" side-panel section: who the external parties on the + * current email thread / calendar event are, the company they belong to, and + * the recent email history with them. + * + * Renders nothing when `parties` is empty, which is how the "external parties + * only" rule is enforced — callers pass the output of + * {@link import('./parties').externalParties}. + */ +export function CorrespondenceSidePanelSection(props: { + parties: CorrespondenceParty[]; + /** Render order within the panel — lower numbers appear first. */ + order?: number; +}) { + return ( + 0}> + + + + + ); +} + +function CorrespondenceContent(props: { parties: CorrespondenceParty[] }) { + const domains = createMemo(() => partyDomains(props.parties)); + const addresses = createMemo(() => props.parties.map((p) => p.email)); + + return ( +
+ + + {(party) => } + + + + + {(domain) => } + + + + + +
+ ); +} + +function Block(props: ParentProps<{ label: JSX.Element }>) { + return ( +
+
+ {props.label} +
+ {props.children} +
+ ); +} + +/** + * One external party. Opens their CRM contact record when the team tracks + * one; otherwise it is a plain, inert row showing what the thread/event knows + * about them. + */ +function ContactRow(props: { party: CorrespondenceParty }) { + const { replaceOrInsertSplit } = useSplitLayout(); + const { contact } = useCrmContactForAddress(() => props.party.email); + + const label = () => contact()?.name ?? props.party.name ?? props.party.email; + + return ( + + ); +} + +/** + * The CRM company behind an external domain. Falls back to the bare domain + * once the lookup settles without a match — the team simply doesn't track a + * company for it yet. + */ +function CompanyRow(props: { domain: string }) { + const { replaceOrInsertSplit } = useSplitLayout(); + const { company, isLoading } = useCrmCompanyForDomain(() => props.domain); + + return ( + }> +
{props.domain}
+
+ } + > + {(record) => ( + + )} + + ); +} + +/** + * The most recent shared email threads, capped at + * {@link CORRESPONDENCE_THREAD_LIMIT} and scrolled inside a fixed-height box + * so a chatty correspondent can't stretch the panel. + */ +function RecentThreads(props: { addresses: string[] }) { + const threadsQuery = useCorrespondenceThreadsQuery(() => props.addresses); + const threads = createMemo(() => + (threadsQuery.data?.entities ?? []).slice(0, CORRESPONDENCE_THREAD_LIMIT) + ); + + const [listRef, setListRef] = createSignal(); + + return ( + }> + }> + 0} + fallback={ +
+ No emails with these contacts yet. +
+ } + > +
+ + +
+ + {(entity) => ( + + openEntityInSplitFromUnifiedList(entity, {}) + } + /> + )} + +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/correspondence/index.ts b/apps/web/src/features/correspondence/index.ts new file mode 100644 index 00000000000..3d16c6a3c47 --- /dev/null +++ b/apps/web/src/features/correspondence/index.ts @@ -0,0 +1,7 @@ +export { CorrespondenceSidePanelSection } from './CorrespondenceSection'; +export { + addressDomain, + type CorrespondenceParty, + externalParties, + partyDomains, +} from './parties'; diff --git a/apps/web/src/features/correspondence/parties.test.ts b/apps/web/src/features/correspondence/parties.test.ts new file mode 100644 index 00000000000..ea2e22f1877 --- /dev/null +++ b/apps/web/src/features/correspondence/parties.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { addressDomain, externalParties, partyDomains } from './parties'; + +describe('addressDomain', () => { + it('lowercases and trims the domain part', () => { + expect(addressDomain(' Jane@Acme.COM ')).toBe('acme.com'); + }); + + it('returns undefined for addresses without a usable domain', () => { + expect(addressDomain(undefined)).toBeUndefined(); + expect(addressDomain('')).toBeUndefined(); + expect(addressDomain('jane')).toBeUndefined(); + expect(addressDomain('@acme.com')).toBeUndefined(); + expect(addressDomain('jane@')).toBeUndefined(); + }); +}); + +describe('externalParties', () => { + const self = 'me@macro.com'; + + it('drops the signed-in user and same-domain teammates', () => { + expect( + externalParties( + [ + { email: 'me@macro.com' }, + { email: 'teammate@macro.com', name: 'Team Mate' }, + { email: 'jane@acme.com', name: 'Jane' }, + ], + self + ) + ).toEqual([{ email: 'jane@acme.com', name: 'Jane' }]); + }); + + it('matches the user and their domain case-insensitively', () => { + expect( + externalParties( + [{ email: 'ME@Macro.com' }, { email: 'Teammate@MACRO.com' }], + 'me@macro.com' + ) + ).toEqual([]); + }); + + it('dedupes on the address, keeping the first display name it sees', () => { + expect( + externalParties( + [ + { email: 'jane@acme.com' }, + { email: 'Jane@Acme.com', name: 'Jane Doe' }, + { email: 'jane@acme.com', name: 'J. Doe' }, + ], + self + ) + ).toEqual([{ email: 'jane@acme.com', name: 'Jane Doe' }]); + }); + + it('treats blank display names as absent', () => { + expect( + externalParties([{ email: 'jane@acme.com', name: ' ' }], self) + ).toEqual([{ email: 'jane@acme.com', name: undefined }]); + }); + + it('skips malformed addresses', () => { + expect( + externalParties([{ email: 'not-an-address' }, { email: '' }], self) + ).toEqual([]); + }); + + it('returns nothing when the signed-in address is unknown', () => { + const parties = [{ email: 'jane@acme.com' }]; + expect(externalParties(parties, undefined)).toEqual([]); + expect(externalParties(parties, 'nodomain')).toEqual([]); + }); +}); + +describe('partyDomains', () => { + it('returns distinct domains in first-seen order', () => { + expect( + partyDomains([ + { email: 'jane@acme.com' }, + { email: 'bo@globex.com' }, + { email: 'ann@acme.com' }, + ]) + ).toEqual(['acme.com', 'globex.com']); + }); +}); diff --git a/apps/web/src/features/correspondence/parties.ts b/apps/web/src/features/correspondence/parties.ts new file mode 100644 index 00000000000..19f45d0efc6 --- /dev/null +++ b/apps/web/src/features/correspondence/parties.ts @@ -0,0 +1,76 @@ +/** + * Deriving the *external* people on an email thread or a calendar event. + * + * "External" is defined the way the Correspondence panel needs it: everybody + * who is neither the signed-in user nor on the user's own email domain. That + * domain comparison is the whole internal/external test — a teammate on a + * second company domain reads as external, which is the trade the simple rule + * buys. + */ + +/** A person on an email thread or a calendar event. */ +export interface CorrespondenceParty { + /** Lowercased email address — the identity key for a party. */ + email: string; + /** Display name observed for the address, when the source carried one. */ + name?: string; +} + +/** + * The lowercased domain of `email`, or `undefined` when the address has no + * usable domain part (empty input, a bare local part, a trailing `@`). + */ +export function addressDomain(email: string | undefined): string | undefined { + if (!email) return undefined; + const trimmed = email.trim(); + const at = trimmed.lastIndexOf('@'); + if (at <= 0 || at === trimmed.length - 1) return undefined; + return trimmed.slice(at + 1).toLowerCase(); +} + +/** + * Narrows raw participants down to the external ones, deduped on the + * lowercased address (first non-empty display name wins) and returned in + * first-seen order. + * + * Returns an empty list when the signed-in user's own address is unknown or + * domainless: without a domain to compare against there is no way to tell a + * teammate from a customer, and guessing would put colleagues in the panel. + */ +export function externalParties( + parties: Iterable, + selfEmail: string | undefined +): CorrespondenceParty[] { + const self = selfEmail?.trim().toLowerCase(); + const internalDomain = addressDomain(self); + if (!self || !internalDomain) return []; + + const byAddress = new Map(); + for (const party of parties) { + const email = party.email?.trim().toLowerCase(); + if (!email || email === self) continue; + + const domain = addressDomain(email); + if (!domain || domain === internalDomain) continue; + + const name = party.name?.trim() || undefined; + const existing = byAddress.get(email); + if (!existing) { + byAddress.set(email, { email, name }); + } else if (!existing.name && name) { + existing.name = name; + } + } + + return [...byAddress.values()]; +} + +/** The distinct domains across `parties`, in first-seen order. */ +export function partyDomains(parties: CorrespondenceParty[]): string[] { + const domains = new Set(); + for (const party of parties) { + const domain = addressDomain(party.email); + if (domain) domains.add(domain); + } + return [...domains]; +} diff --git a/apps/web/src/features/correspondence/use-correspondence-threads.ts b/apps/web/src/features/correspondence/use-correspondence-threads.ts new file mode 100644 index 00000000000..05f4f30f759 --- /dev/null +++ b/apps/web/src/features/correspondence/use-correspondence-threads.ts @@ -0,0 +1,42 @@ +import { NIL_UUID } from '@app/features/next-soup/filters/filter-store'; +import { emailFilterForAddresses } from '@companies/Company/emailFilter'; +import { useSoupAstItemsQuery } from '@queries/soup/items'; +import type { Accessor } from 'solid-js'; + +/** How many threads the Correspondence section loads. */ +export const CORRESPONDENCE_THREAD_LIMIT = 50; + +/** + * The most recent email threads shared with `addresses`, newest first. + * + * Uses the raw `ef` any-direction OR-tree rather than the CRM-scoped `eca` + * widener, so the query stays inside the caller's own mailbox scope and works + * whether or not the team has CRM enabled for these correspondents. Every + * non-email soup source is switched off with the NIL-UUID sentinel. + * + * A single page of {@link CORRESPONDENCE_THREAD_LIMIT} is all the section + * shows — it scrolls rather than paginating. + */ +export function useCorrespondenceThreadsQuery(addresses: Accessor) { + return useSoupAstItemsQuery( + () => ({ + params: { + limit: CORRESPONDENCE_THREAD_LIMIT, + sort_method: 'updated_at', + }, + body: { + df: { l: { id: NIL_UUID } }, + chanf: { l: { ChannelId: NIL_UUID } }, + cthf: { l: { ThreadId: NIL_UUID } }, + cf: { l: { cid: NIL_UUID } }, + pf: { l: { pid: NIL_UUID } }, + callf: { l: { CallId: NIL_UUID } }, + ccf: { l: { id: NIL_UUID } }, + fef: { l: { id: NIL_UUID } }, + emailView: 'all', + ef: emailFilterForAddresses(addresses()), + }, + }), + () => ({ enabled: addresses().length > 0 }) + ); +} diff --git a/apps/web/src/features/correspondence/use-crm-records.ts b/apps/web/src/features/correspondence/use-crm-records.ts new file mode 100644 index 00000000000..70789b9314b --- /dev/null +++ b/apps/web/src/features/correspondence/use-crm-records.ts @@ -0,0 +1,45 @@ +import { useCompanyQuery } from '@queries/crm/companies'; +import { useCrmCompanyIdForDomainQuery } from '@queries/crm/company-for-domain'; +import { type Accessor, createMemo } from 'solid-js'; +import { addressDomain } from './parties'; + +/** + * The caller's team CRM company for an email domain, with its contacts. + * + * Two hops — domain → company id (unified search), then id → company + + * contacts — both cached by TanStack Query, so the rows in a panel that all + * share one domain issue one pair of requests between them. + */ +export function useCrmCompanyForDomain(domain: Accessor) { + const companyIdQuery = useCrmCompanyIdForDomainQuery(domain); + const companyId = () => companyIdQuery.data ?? ''; + const { query, company, contacts } = useCompanyQuery(companyId); + + return { + company, + contacts, + /** True while either hop is still in flight. */ + isLoading: () => + companyIdQuery.isLoading || (!!companyId() && query.isLoading), + }; +} + +/** + * The CRM contact record for a single address, resolved through the company + * that owns its domain. `contact` stays `undefined` when the team tracks no + * company for the domain, or tracks the company but not this address. + */ +export function useCrmContactForAddress(email: Accessor) { + const domain = createMemo(() => addressDomain(email())); + const { company, contacts, isLoading } = useCrmCompanyForDomain(domain); + + const contact = createMemo(() => { + const target = email().trim().toLowerCase(); + if (!target) return undefined; + return contacts().find( + (candidate) => candidate.email.trim().toLowerCase() === target + ); + }); + + return { contact, company, isLoading }; +} diff --git a/apps/web/src/lib/queries/crm/company-for-domain.ts b/apps/web/src/lib/queries/crm/company-for-domain.ts new file mode 100644 index 00000000000..3011361d164 --- /dev/null +++ b/apps/web/src/lib/queries/crm/company-for-domain.ts @@ -0,0 +1,100 @@ +import { NIL_UUID } from '@app/features/next-soup/filters/filter-store'; +import { ENABLE_SEARCH_SERVICE } from '@core/constant/featureFlags'; +import { throwOnErr } from '@core/util/result'; +import { searchClient } from '@service-search/client'; +import type { EntityFilters } from '@service-search/generated/models/entityFilters'; +import type { UnifiedSearchResponseItem } from '@service-search/generated/models/unifiedSearchResponseItem'; +import { useQuery } from '@tanstack/solid-query'; +import type { Accessor } from 'solid-js'; +import { crmKeys } from './keys'; + +/** The `company` arm of the unified-search result union. */ +type CompanySearchHit = Extract; + +/** + * Unified search scoped to CRM companies only. Every other source is + * switched off with the NIL-UUID sentinel in its primary id field — the + * same mechanism the soup views use (see `SearchEntityFilters::from` + * server-side, which reads `call_filters.channel_ids` rather than + * `call_ids` for call records). + */ +const CRM_ONLY_FILTERS: EntityFilters = { + document_filters: { document_ids: [NIL_UUID] }, + chat_filters: { chat_ids: [NIL_UUID] }, + email_filters: { email_thread_ids: [NIL_UUID] }, + channel_filters: { channel_ids: [NIL_UUID] }, + project_filters: { project_ids: [NIL_UUID] }, + call_filters: { channel_ids: [NIL_UUID] }, + // Visible companies only; the hidden set is admin-gated server-side. + crm_company_filters: { hidden: false }, +}; + +/** The search service rejects queries shorter than this. */ +const MIN_SEARCH_LENGTH = 3; + +/** + * Company domains change rarely, and the panel re-resolves the same handful + * of domains on every thread/event switch — a generous stale time keeps that + * to one request per domain per session. + */ +const DOMAIN_LOOKUP_STALE_TIME = 5 * 60 * 1000; + +/** + * Enough headroom that an exact-domain company isn't pushed off the page by + * substring matches (`acme.com` also matches `notacme.com`, `acme.com.br`, …) + * without paying for a large response. + */ +const DOMAIN_LOOKUP_PAGE_SIZE = 25; + +/** + * Resolves an email domain to the id of the caller's team CRM company that + * owns it, or `null` when the team tracks no company for that domain. + * + * There is no domain lookup endpoint on the CRM service, so this rides the + * unified-search CRM source, whose match runs over `crm_domains.domain` as + * well as the company name. That match is a case-insensitive *substring*, so + * the hit is only accepted when one of the company's domains equals the + * requested one exactly — a search for `acme.com` must never resolve to + * `notacme.com`. + * + * Pair with {@link useCompanyQuery} to hydrate the company and its contacts. + */ +export function useCrmCompanyIdForDomainQuery( + domain: Accessor +) { + return useQuery(() => { + const target = domain()?.trim().toLowerCase(); + return { + queryKey: crmKeys.companyForDomain(target ?? '').queryKey, + queryFn: async () => { + if (!target) return null; + const response = await throwOnErr(() => + searchClient.search({ + params: { page_size: DOMAIN_LOOKUP_PAGE_SIZE }, + request: { + query: target, + match_type: 'partial', + // CRM has no content index; it only participates under + // name / name_content searches. + search_on: 'name', + include_crm: true, + filters: CRM_ONLY_FILTERS, + }, + }) + ); + + const match = response.results.find( + (result): result is CompanySearchHit => + result.type === 'company' && + result.domains.some( + (entry) => entry.domain.trim().toLowerCase() === target + ) + ); + return match?.id ?? null; + }, + staleTime: DOMAIN_LOOKUP_STALE_TIME, + enabled: + ENABLE_SEARCH_SERVICE && !!target && target.length >= MIN_SEARCH_LENGTH, + }; + }); +} diff --git a/apps/web/src/lib/queries/crm/keys.ts b/apps/web/src/lib/queries/crm/keys.ts index 3b84246fa88..7c5c760dd21 100644 --- a/apps/web/src/lib/queries/crm/keys.ts +++ b/apps/web/src/lib/queries/crm/keys.ts @@ -3,6 +3,7 @@ import type { CrmCommentEntityType } from '@service-storage/generated/schemas/cr export const crmKeys = createQueryKeys('crm', { company: (companyId: string) => [companyId], + companyForDomain: (domain: string) => [domain], contact: (contactId: string) => [contactId], comments: (entityType: CrmCommentEntityType, entityId: string) => [ entityType,