Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel. - Implemented dynamic loading for admin settings sections to optimize performance. - Enhanced the layout of various admin settings sections for better user experience. - Updated the AiAssistantSection to include LLM provider management and improved model selection. - Refactored authentication settings to streamline configuration and improve accessibility.
856 lines
27 KiB
TypeScript
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: (data) => {
|
|
queryClient.setQueryData(['search-settings'], normalizeSearchSettings(data))
|
|
},
|
|
})
|
|
}
|
|
|
|
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…'
|
|
}
|
|
}
|