ultisuite-client/lib/api/hooks/use-contact-discovery.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

856 lines
27 KiB
TypeScript

'use client'
import { useMemo } from 'react'
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from '@tanstack/react-query'
import { appendContactToBookCache } from '../contact-book-cache'
import { apiClient, ApiRequestError, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { ApiContact } from '../types'
import type {
ApiDiscoveryCounts,
ApiDiscoveryScan,
ApiDiscoveredProfile,
ApiDiscoveredProfileGroup,
ApiDispositionEmails,
ApiEnrichmentSuggestion,
ApiLLMModelsResponse,
ApiLLMSettings,
ApiSearchSettings,
} from '@/lib/contacts/discovery-types'
import {
coerceDiscoveryGroups,
dedupeDiscoveryGroups,
discoveryGroupReactKey,
parseDiscoveryOtherPage,
parseDiscoveryOtherResponse,
type DiscoveryOtherPageResult,
} from '@/lib/contacts/discovery-grouping'
import { filterVisibleEnrichmentSuggestions } from '@/lib/contacts/discovery-utils'
import { useContactsList } from '@/lib/contacts/use-contacts-list'
const ACTIVE_SCAN_STORAGE_KEY = 'ultimail-contact-discovery-scan-id'
export function getPersistedScanId(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(ACTIVE_SCAN_STORAGE_KEY)
}
export function persistScanId(scanId: string | null) {
if (typeof window === 'undefined') return
if (scanId) {
localStorage.setItem(ACTIVE_SCAN_STORAGE_KEY, scanId)
} else {
localStorage.removeItem(ACTIVE_SCAN_STORAGE_KEY)
}
}
export function useDiscoveryCounts() {
return useQuery({
queryKey: ['contact-discovery-counts'],
queryFn: () => apiClient.get<ApiDiscoveryCounts>('/contacts/discovery/counts'),
staleTime: 30_000,
})
}
export const OTHER_CONTACTS_PAGE_SIZE = 12
const OTHER_CONTACTS_QUERY_KEY = ['contact-discovery-other', 'groups', 'infinite'] as const
function otherContactsQueryKey(searchQuery: string) {
const q = searchQuery.trim()
return q ? ([...OTHER_CONTACTS_QUERY_KEY, q] as const) : OTHER_CONTACTS_QUERY_KEY
}
/** Cache session si l'API renvoie encore toute la liste d'un coup (legacy). */
let legacyOtherContactsFullList: ApiDiscoveredProfileGroup[] | null = null
function sliceLegacyOtherContactsPage(offset: number): DiscoveryOtherPageResult | null {
if (!legacyOtherContactsFullList?.length) return null
const groups = legacyOtherContactsFullList.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
return {
groups,
total: legacyOtherContactsFullList.length,
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < legacyOtherContactsFullList.length,
}
}
export function flattenOtherContactPages(
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
): ApiDiscoveredProfileGroup[] {
if (!data?.pages?.length) return []
return dedupeDiscoveryGroups(data.pages.flatMap((page) => page.groups ?? []))
}
function dedupeOtherContactsInfiniteCache(
data: InfiniteData<DiscoveryOtherPageResult> | undefined,
): InfiniteData<DiscoveryOtherPageResult> | undefined {
if (!data?.pages?.length) return data
const seen = new Set<string>()
const pages = data.pages.map((page) => ({
...page,
groups: dedupeDiscoveryGroups(page.groups ?? []).filter((group) => {
const key = discoveryGroupReactKey(group)
if (seen.has(key)) return false
seen.add(key)
return true
}),
}))
return { ...data, pages }
}
export function useOtherDiscoveredContacts(enabled = true, searchQuery = '') {
const trimmedSearch = searchQuery.trim()
return useInfiniteQuery({
queryKey: otherContactsQueryKey(trimmedSearch),
queryFn: async ({ pageParam }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
const cachedLegacy = offset > 0 && !trimmedSearch ? sliceLegacyOtherContactsPage(offset) : null
if (cachedLegacy) {
return {
groups: coerceDiscoveryGroups(cachedLegacy.groups),
total: cachedLegacy.total,
hasMore: cachedLegacy.hasMore,
}
}
const res = await apiClient.get<{
groups?: ApiDiscoveredProfileGroup[]
profiles?: ApiDiscoveredProfile[]
total?: number
has_more?: boolean
limit?: number
offset?: number
}>('/contacts/discovery/other', {
limit: String(OTHER_CONTACTS_PAGE_SIZE),
offset: String(offset),
...(trimmedSearch ? { q: trimmedSearch } : {}),
})
const allGroups = coerceDiscoveryGroups(parseDiscoveryOtherResponse(res))
const serverPaginated =
('has_more' in res && res.has_more != null) || typeof res.total === 'number'
if (!serverPaginated && allGroups.length > OTHER_CONTACTS_PAGE_SIZE) {
legacyOtherContactsFullList = allGroups
const groups = allGroups.slice(offset, offset + OTHER_CONTACTS_PAGE_SIZE)
return {
groups,
total: allGroups.length,
hasMore: offset + OTHER_CONTACTS_PAGE_SIZE < allGroups.length,
}
}
legacyOtherContactsFullList = null
const page = parseDiscoveryOtherPage(res, {
offset,
pageSize: OTHER_CONTACTS_PAGE_SIZE,
})
return {
groups: coerceDiscoveryGroups(page.groups ?? []),
total: page.total,
hasMore: page.hasMore,
}
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (!lastPage?.hasMore) return undefined
return allPages.reduce((sum, p) => sum + (p.groups?.length ?? 0), 0)
},
staleTime: 30_000,
enabled,
refetchInterval: (query) => {
const groups = flattenOtherContactPages(query.state.data)
return groups.some((g) => g.profile?.enrichment_status === 'enriching') ? 2000 : false
},
})
}
export function useIgnoredDiscoveredContacts() {
return useQuery({
queryKey: ['contact-discovery-ignored'],
queryFn: async () => {
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
'/contacts/discovery/ignored',
)
return res.profiles ?? []
},
staleTime: 30_000,
})
}
export function useBlockedDiscoveredContacts() {
return useQuery({
queryKey: ['contact-discovery-blocked'],
queryFn: async () => {
const res = await apiClient.get<{ profiles: ApiDiscoveredProfile[] }>(
'/contacts/discovery/blocked',
)
return res.profiles ?? []
},
staleTime: 30_000,
})
}
export function useEnrichmentSuggestions(type?: 'enrich' | 'all') {
return useQuery({
queryKey: ['contact-discovery-suggestions', type],
queryFn: async () => {
const res = await apiClient.get<{ suggestions: ApiEnrichmentSuggestion[] }>(
'/contacts/discovery/suggestions',
type === 'enrich' ? { type: 'enrich' } : undefined,
)
return res.suggestions ?? []
},
staleTime: 30_000,
})
}
/** Enrichment suggestions for existing contacts, excluding values already on the contact. */
export function useVisibleEnrichmentSuggestions() {
const { contacts } = useContactsList()
const query = useEnrichmentSuggestions('enrich')
const suggestions = useMemo(
() => filterVisibleEnrichmentSuggestions(query.data ?? [], contacts),
[query.data, contacts],
)
return { ...query, suggestions }
}
export function useActiveDiscoveryScan() {
return useQuery({
queryKey: ['contact-discovery-scan-active'],
queryFn: async () => {
const res = await apiClient.get<{ scan: ApiDiscoveryScan | null }>(
'/contacts/discovery/scan/active',
)
const scan = res.scan ?? null
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
persistScanId(scan.id)
} else if (!scan) {
persistScanId(null)
}
return scan
},
refetchInterval: (query) => {
const scan = query.state.data
if (scan && (scan.status === 'running' || scan.status === 'pending')) {
return 2000
}
return false
},
staleTime: 0,
})
}
export function useDiscoveryScan(scanId?: string) {
return useQuery({
queryKey: ['contact-discovery-scan', scanId],
queryFn: () => apiClient.get<ApiDiscoveryScan>(`/contacts/discovery/scan/${scanId}`),
enabled: !!scanId,
refetchInterval: (query) => {
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 2000 : false
},
})
}
export function useStartDiscoveryScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (bookId?: string) => {
const path = bookId
? `/contacts/discovery/scan?book_id=${encodeURIComponent(bookId)}`
: '/contacts/discovery/scan'
return apiClient.post<ApiDiscoveryScan>(path)
},
onSuccess: (scan) => {
persistScanId(scan.id)
queryClient.setQueryData(['contact-discovery-scan-active'], scan)
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan', scan.id] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-ignored'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-blocked'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
},
})
}
export function useCancelDiscoveryScan() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiClient.post<void>('/contacts/discovery/scan/cancel'),
onSuccess: () => {
persistScanId(null)
queryClient.setQueryData(['contact-discovery-scan-active'], null)
queryClient.invalidateQueries({ queryKey: ['contact-discovery-scan-active'] })
},
})
}
function groupMatchesProfileId(group: ApiDiscoveredProfileGroup, profileId: string): boolean {
if (group.profile?.id === profileId) return true
if (group.profile_ids?.includes(profileId)) return true
return group.profiles?.some((p) => p.id === profileId) ?? false
}
function findOtherContactGroupInCache(
queryClient: QueryClient,
profileId: string,
): ApiDiscoveredProfileGroup | undefined {
const data = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
)
if (!data?.pages?.length) return undefined
for (const page of data.pages) {
for (const group of page.groups ?? []) {
if (groupMatchesProfileId(group, profileId)) return group
}
}
return undefined
}
/** Emails liés à un groupe découvert (pour blocage optimiste côté client). */
export function extractDiscoveryGroupEmails(group: ApiDiscoveredProfileGroup): string[] {
const emails = new Set<string>()
if (group.primary_email) emails.add(group.primary_email.toLowerCase())
for (const profile of group.profiles ?? (group.profile ? [group.profile] : [])) {
if (profile.primary_email) emails.add(profile.primary_email.toLowerCase())
for (const entry of profile.all_emails ?? []) {
if (entry.email) emails.add(entry.email.toLowerCase())
}
}
return [...emails]
}
export function removeOtherContactGroupFromCache(
queryClient: QueryClient,
profileId: string,
): boolean {
let removed = false
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
(old) => {
if (!old) return old
let didRemove = false
const pages = old.pages.map((page) => {
const groups = (page.groups ?? []).filter((group) => {
if (groupMatchesProfileId(group, profileId)) {
didRemove = true
return false
}
return true
})
return { ...page, groups }
})
if (didRemove && pages[0]) {
removed = true
pages[0] = {
...pages[0],
total: Math.max(0, pages[0].total - 1),
}
}
return { ...old, pages }
},
)
if (removed) {
if (legacyOtherContactsFullList) {
legacyOtherContactsFullList = legacyOtherContactsFullList.filter(
(group) => !groupMatchesProfileId(group, profileId),
)
}
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return {
...old,
other_contacts: Math.max(0, old.other_contacts - 1),
}
})
}
return removed
}
interface OptimisticDispositionContext {
previous: InfiniteData<DiscoveryOtherPageResult> | undefined
previousCounts: ApiDiscoveryCounts | undefined
previousIgnored: ApiDiscoveredProfile[] | undefined
previousBlocked: ApiDiscoveredProfile[] | undefined
removedGroup: ApiDiscoveredProfileGroup | undefined
}
function rollbackDispositionContext(
queryClient: QueryClient,
context: OptimisticDispositionContext | undefined,
) {
if (!context) return
if (context.previous) {
queryClient.setQueryData(OTHER_CONTACTS_QUERY_KEY, context.previous)
}
if (context.previousCounts) {
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
}
if (context.previousIgnored) {
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
}
if (context.previousBlocked) {
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
}
}
function optimisticRemoveFromDispositionLists(
queryClient: QueryClient,
profileId: string,
): Pick<
OptimisticDispositionContext,
'previousIgnored' | 'previousBlocked' | 'previousCounts'
> {
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-ignored',
])
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-blocked',
])
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
'contact-discovery-counts',
])
let removedIgnored = false
let removedBlocked = false
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
if (!old) return old
const next = old.filter((p) => p.id !== profileId)
removedIgnored = next.length !== old.length
return next
})
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
if (!old) return old
const next = old.filter((p) => p.id !== profileId)
removedBlocked = next.length !== old.length
return next
})
if (removedIgnored || removedBlocked) {
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return {
...old,
ignored: removedIgnored ? Math.max(0, old.ignored - 1) : old.ignored,
blocked: removedBlocked ? Math.max(0, old.blocked - 1) : old.blocked,
}
})
}
return { previousIgnored, previousBlocked, previousCounts }
}
function optimisticRemoveOtherContact(
queryClient: QueryClient,
profileId: string,
): OptimisticDispositionContext {
const previous = queryClient.getQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
)
const previousCounts = queryClient.getQueryData<ApiDiscoveryCounts>([
'contact-discovery-counts',
])
const previousIgnored = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-ignored',
])
const previousBlocked = queryClient.getQueryData<ApiDiscoveredProfile[]>([
'contact-discovery-blocked',
])
const removedGroup = findOtherContactGroupInCache(queryClient, profileId)
removeOtherContactGroupFromCache(queryClient, profileId)
void queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
return {
previous,
previousCounts,
previousIgnored,
previousBlocked,
removedGroup,
}
}
function prependIgnoredProfile(
queryClient: QueryClient,
group: ApiDiscoveredProfileGroup | undefined,
) {
const profile = group?.profile ?? group?.profiles?.[0]
if (!profile) return
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-ignored'], (old) => {
const list = old ?? []
if (list.some((p) => p.id === profile.id)) return list
return [{ ...profile, status: 'ignored' as const }, ...list]
})
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return { ...old, ignored: old.ignored + 1 }
})
}
function prependBlockedProfile(
queryClient: QueryClient,
group: ApiDiscoveredProfileGroup | undefined,
) {
const profile = group?.profile ?? group?.profiles?.[0]
if (!profile) return
queryClient.setQueryData<ApiDiscoveredProfile[]>(['contact-discovery-blocked'], (old) => {
const list = old ?? []
if (list.some((p) => p.id === profile.id)) return list
return [{ ...profile, status: 'blocked' as const }, ...list]
})
queryClient.setQueryData<ApiDiscoveryCounts>(['contact-discovery-counts'], (old) => {
if (!old) return old
return { ...old, blocked: old.blocked + 1 }
})
}
export function useAcceptDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (vars: { profileId: string; contactUid?: string }) =>
apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
contact_uid: vars.contactUid ?? '',
}),
onMutate: (vars) => {
return optimisticRemoveOtherContact(queryClient, vars.profileId)
},
onError: (_err, _vars, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
/** Crée le contact NC puis marque le profil découvert comme accepté (retrait optimiste immédiat). */
export function useAddDiscoveredContact() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (vars: {
bookId: string
profileId: string
contact: Partial<ApiContact>
}) => {
try {
const created = await apiClient.post<ApiContact>(
`/contacts/discovery/profiles/${vars.profileId}/add-to-book`,
{ book_id: vars.bookId, contact: vars.contact },
)
return { created, contactUid: created.uid }
} catch (err) {
if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 405)) {
throw err
}
const created = await apiClient.post<ApiContact | undefined>(
`/contacts/books/${vars.bookId}`,
vars.contact,
)
const contactUid = created?.uid ?? vars.contact.uid ?? ''
await apiClient.post<void>(`/contacts/discovery/profiles/${vars.profileId}/accept`, {
contact_uid: contactUid,
})
const merged: ApiContact = {
...(vars.contact as ApiContact),
...(created ?? {}),
uid: contactUid,
}
return { created: merged, contactUid }
}
},
onMutate: (vars) => {
return optimisticRemoveOtherContact(queryClient, vars.profileId)
},
onSuccess: (data, vars) => {
if (data.created.uid) {
appendContactToBookCache(queryClient, vars.bookId, data.created)
}
},
onError: (err, _vars, context) => {
rollbackDispositionContext(queryClient, context)
if (err instanceof OfflineError) {
enqueue({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'create_contact',
payload: { bookId: _vars.bookId, ..._vars.contact },
retries: 0,
})
}
},
})
}
export function useRejectDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<void>(`/contacts/discovery/profiles/${profileId}/reject`),
onMutate: (profileId) => {
const dispositionContext = optimisticRemoveFromDispositionLists(queryClient, profileId)
void queryClient.cancelQueries({ queryKey: ['contact-discovery-ignored'] })
void queryClient.cancelQueries({ queryKey: ['contact-discovery-blocked'] })
return dispositionContext
},
onError: (_err, _profileId, context) => {
if (!context) return
if (context.previousIgnored) {
queryClient.setQueryData(['contact-discovery-ignored'], context.previousIgnored)
}
if (context.previousBlocked) {
queryClient.setQueryData(['contact-discovery-blocked'], context.previousBlocked)
}
if (context.previousCounts) {
queryClient.setQueryData(['contact-discovery-counts'], context.previousCounts)
}
},
})
}
export function useIgnoreDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ApiDispositionEmails>(
`/contacts/discovery/profiles/${profileId}/ignore`,
),
onMutate: (profileId) => {
const context = optimisticRemoveOtherContact(queryClient, profileId)
prependIgnoredProfile(queryClient, context.removedGroup)
return context
},
onError: (_err, _profileId, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
export function useBlockDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ApiDispositionEmails>(
`/contacts/discovery/profiles/${profileId}/block`,
),
onMutate: (profileId) => {
const context = optimisticRemoveOtherContact(queryClient, profileId)
prependBlockedProfile(queryClient, context.removedGroup)
return context
},
onError: (_err, _profileId, context) => {
rollbackDispositionContext(queryClient, context)
},
})
}
export interface ProfileEnrichResponse {
profile_id: string
enrichment_status: ApiDiscoveredProfile['enrichment_status']
}
function patchGroupEnrichmentStatus(
groups: ApiDiscoveredProfileGroup[] | undefined,
profileId: string,
status: ApiDiscoveredProfile['enrichment_status'],
): ApiDiscoveredProfileGroup[] | undefined {
if (!groups) return groups
return groups.map((g) => {
const id = g.profile?.id ?? g.profile_ids[0]
if (id !== profileId) return g
const patchProfile = (p: ApiDiscoveredProfile): ApiDiscoveredProfile => ({
...p,
enrichment_status: status,
})
return {
...g,
profile: g.profile ? patchProfile(g.profile) : g.profile,
profiles: g.profiles?.map(patchProfile),
}
})
}
export function useEnrichDiscoveredProfile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (profileId: string) =>
apiClient.post<ProfileEnrichResponse>(
`/contacts/discovery/profiles/${profileId}/enrich`,
),
onMutate: async (profileId) => {
await queryClient.cancelQueries({ queryKey: OTHER_CONTACTS_QUERY_KEY })
queryClient.setQueryData<InfiniteData<DiscoveryOtherPageResult>>(
OTHER_CONTACTS_QUERY_KEY,
(old) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page) => ({
...page,
groups:
patchGroupEnrichmentStatus(page.groups ?? [], profileId, 'enriching') ??
page.groups ??
[],
})),
}
},
)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-other'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
},
})
}
export function useAcceptEnrichmentSuggestion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (suggestionId: string) =>
apiClient.post<ApiEnrichmentSuggestion>(
`/contacts/discovery/suggestions/${suggestionId}/accept`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
queryClient.invalidateQueries({ queryKey: ['contacts'] })
},
})
}
export function useRejectEnrichmentSuggestion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (suggestionId: string) =>
apiClient.post<void>(`/contacts/discovery/suggestions/${suggestionId}/reject`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contact-discovery-counts'] })
queryClient.invalidateQueries({ queryKey: ['contact-discovery-suggestions'] })
},
})
}
function normalizeLLMSettings(raw: ApiLLMSettings): ApiLLMSettings {
return {
...raw,
default_provider_id: raw.default_provider_id ?? '',
providers: raw.providers ?? [],
}
}
export function useLLMSettings() {
return useQuery({
queryKey: ['llm-settings'],
queryFn: async () => {
const data = await apiClient.get<ApiLLMSettings>('/contacts/discovery/llm-settings')
return normalizeLLMSettings(data)
},
staleTime: 60_000,
})
}
export function useUpdateLLMSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (settings: ApiLLMSettings) =>
apiClient.put<ApiLLMSettings>('/contacts/discovery/llm-settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['llm-settings'] })
},
})
}
function normalizeSearchSettings(raw: ApiSearchSettings): ApiSearchSettings {
return {
...raw,
default_provider_id: raw.default_provider_id ?? '',
providers: raw.providers ?? [],
}
}
export function useSearchSettings() {
return useQuery({
queryKey: ['search-settings'],
queryFn: async () => {
const data = await apiClient.get<ApiSearchSettings>('/contacts/discovery/search-settings')
return normalizeSearchSettings(data)
},
staleTime: 60_000,
})
}
export function useUpdateSearchSettings() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (settings: ApiSearchSettings) =>
apiClient.put<ApiSearchSettings>('/contacts/discovery/search-settings', settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['search-settings'] })
},
})
}
export function useDiscoverLLMModels(baseUrl: string, apiKey: string) {
const trimmedBaseUrl = baseUrl.trim()
const trimmedApiKey = apiKey.trim()
return useQuery({
queryKey: ['llm-models', trimmedBaseUrl, trimmedApiKey],
queryFn: () =>
apiClient.post<ApiLLMModelsResponse>('/contacts/discovery/llm-models/discover', {
base_url: trimmedBaseUrl,
api_key: trimmedApiKey || undefined,
}),
enabled: trimmedBaseUrl.length > 0,
staleTime: 5 * 60_000,
retry: false,
})
}
export function scanProgressLabel(scan: ApiDiscoveryScan): string {
const phase = scanPhaseLabel(scan.phase)
if (scan.phase === 'enriching') {
const done = scan.profiles_found
const total = scan.profiles_total
if (total > 0) {
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} contacts`
}
return phase
}
if (scan.phase === 'building_profiles') {
const done = scan.profiles_found
const total = scan.profiles_total
if (total > 0) {
return `${phase} ${done.toLocaleString('fr-FR')} / ${total.toLocaleString('fr-FR')} profils`
}
return phase
}
if (scan.phase === 'scanning_messages') {
if (scan.total_messages > 0) {
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} / ${scan.total_messages.toLocaleString('fr-FR')} messages`
}
return `${phase} ${scan.messages_scanned.toLocaleString('fr-FR')} messages`
}
return phase
}
export function scanPhaseLabel(phase: ApiDiscoveryScan['phase']): string {
switch (phase) {
case 'scanning_messages':
return 'Analyse des messages…'
case 'building_profiles':
return 'Construction des profils…'
case 'enriching':
return 'Enrichissement IA…'
case 'done':
return 'Terminé'
default:
return 'En attente…'
}
}