'use client' import { useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient, OfflineError } from '../client' import { enqueue } from '../offline-queue' import { fullContactToApiContact, buildContactUpdatePayload } from '../adapters' import { appendContactToBookCache, replaceContactInBookCaches, } from '../contact-book-cache' import { invalidateContactListCache } from '../contact-list-cache' import { contactApiPath } from '@/lib/contacts/contact-api-path' import { mergeTwoContacts, mergeManyContacts } from '@/lib/contacts/merge-contacts' import type { FullContact } from '@/lib/contacts/types' import type { ApiContact } from '../types' export function useCreateContact() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { bookId: string; contact: Partial }) => { const created = await apiClient.post( `/contacts/books/${vars.bookId}`, vars.contact, ) if (created?.uid) return created return vars.contact as ApiContact }, onSuccess: (data, vars) => { const contact = data?.uid ? data : (vars.contact as ApiContact) if (contact?.uid) { appendContactToBookCache(queryClient, vars.bookId, { ...contact, etag: contact.etag ?? data?.etag, path: contact.path ?? data?.path, }) } invalidateContactListCache(vars.bookId) queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] }) }, onError: (err, vars) => { if (err instanceof OfflineError) { enqueue({ id: crypto.randomUUID(), timestamp: Date.now(), type: 'create_contact', payload: { bookId: vars.bookId, ...vars.contact }, retries: 0, }) } }, }) } export function useUpdateContact() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { path: string contact: Partial etag?: string skipInvalidation?: boolean }) => { const ifMatch = vars.etag ?? vars.contact.etag const headers: Record = {} if (ifMatch) { headers['If-Match'] = ifMatch } const { etag: _bodyEtag, ...contactBody } = vars.contact const apiPath = vars.path.replace(/^\/+/, '') return apiClient.put<{ etag?: string }>(`/contacts/${apiPath}`, contactBody, headers) }, onSuccess: (data, vars) => { const apiPath = vars.path.replace(/^\/+/, '') const nextEtag = data?.etag ?? vars.etag replaceContactInBookCaches(queryClient, apiPath, { ...vars.contact, etag: nextEtag, }) invalidateContactListCache() if (!vars.skipInvalidation) { queryClient.invalidateQueries({ queryKey: ['contacts'] }) } }, onError: (err, vars) => { if (err instanceof OfflineError) { enqueue({ id: crypto.randomUUID(), timestamp: Date.now(), type: 'update_contact', payload: { path: vars.path, ...vars.contact }, retries: 0, }) } }, }) } export function useDeleteContact() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { path: string; bookId?: string }) => apiClient.delete(`/contacts/${vars.path}`), onMutate: async (vars) => { await queryClient.cancelQueries({ queryKey: ['contacts'] }) const queries = queryClient.getQueriesData({ queryKey: ['contacts'] }) const snapshots: [readonly unknown[], ApiContact[] | undefined][] = [] for (const [key, data] of queries) { if (data) { snapshots.push([key, data]) queryClient.setQueryData( key, data.filter((c) => c.path !== vars.path && c.uid !== vars.path) ) } } return { snapshots } }, onError: (err, vars, context) => { if (context?.snapshots) { for (const [key, data] of context.snapshots) { queryClient.setQueryData(key, data) } } if (err instanceof OfflineError) { enqueue({ id: crypto.randomUUID(), timestamp: Date.now(), type: 'delete_contact', payload: { path: vars.path, uid: vars.path }, retries: 0, }) } }, onSettled: () => { invalidateContactListCache() queryClient.invalidateQueries({ queryKey: ['contacts'] }) }, }) } export function useMergeDuplicates() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { bookId: string }) => apiClient.post(`/contacts/books/${vars.bookId}/merge-duplicates`), onSuccess: (_data, vars) => { invalidateContactListCache(vars.bookId) queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] }) }, }) } export function useMergeContactPair() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { bookId: string contactA: FullContact contactB: FullContact }) => { const { primary, secondary, merged } = mergeTwoContacts(vars.contactA, vars.contactB) const primaryPath = contactApiPath(primary) const secondaryPath = contactApiPath(secondary) const headers: Record = {} if (primary.etag) { headers['If-Match'] = primary.etag } await apiClient.put( `/contacts/${primaryPath}`, fullContactToApiContact(merged), headers, ) if (secondaryPath !== primaryPath) { await apiClient.delete(`/contacts/${secondaryPath}`) } }, onSuccess: (_data, vars) => { invalidateContactListCache(vars.bookId) queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] }) }, }) } export function useMergeManyContacts() { const queryClient = useQueryClient() return useMutation({ mutationFn: async (vars: { bookId: string contacts: FullContact[] primaryId?: string }) => { const result = mergeManyContacts(vars.contacts, vars.primaryId) if (!result) { throw new Error('At least 2 contacts are required to merge') } const { primary, secondaries, merged } = result if (!primary.etag) { throw new Error('Cannot merge: unknown contact version. Reload the list.') } const headers: Record = { 'If-Match': primary.etag, } await apiClient.put( `/contacts/${contactApiPath(primary)}`, fullContactToApiContact(merged), headers, ) for (const secondary of secondaries) { const secondaryPath = contactApiPath(secondary) if (secondaryPath !== contactApiPath(primary)) { await apiClient.delete(`/contacts/${secondaryPath}`) } } }, onSuccess: (_data, vars) => { invalidateContactListCache(vars.bookId) queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] }) }, }) }