ultisuite-client/lib/api/hooks/use-compose-mutations.ts
R3D347HR4Y c87670e90f
Some checks failed
E2E / Playwright e2e (push) Has been cancelled
feat(api): offline-first mail sync w/ TanStack Query
Move mail, compose, contacts, and accounts off mocks onto REST + WS.
Add client, auth store, IDB-backed query cache, offline queue, and
sync bar; hybrid Zustand for UI-only state. Settings still local until
backend has preferences API.
2026-05-23 00:04:28 +02:00

219 lines
5.8 KiB
TypeScript

'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient, OfflineError } from '../client'
import { enqueue } from '../offline-queue'
import type { Recipient, ApiOutboxMessage, PaginatedResponse } from '../types'
export interface SendMessagePayload {
account_id: string
to: Recipient[]
cc?: Recipient[]
bcc?: Recipient[]
subject: string
body_html: string
in_reply_to?: string
idempotency_key: string
scheduled_at?: string
}
export type DraftPayload = Omit<SendMessagePayload, 'idempotency_key' | 'scheduled_at'>
export function useSendMessage() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: SendMessagePayload) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/send', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: payload.idempotency_key,
timestamp: Date.now(),
type: 'send_message',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'sent'] })
},
})
}
export function useCreateDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: DraftPayload) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/drafts', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-create-${Date.now()}`,
timestamp: Date.now(),
type: 'create_draft',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
},
})
}
export function useUpdateDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, ...payload }: DraftPayload & { id: string }) => {
try {
return await apiClient.put<ApiOutboxMessage>(`/mail/drafts/${id}`, payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-update-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'update_draft',
payload: { draft_id: id, ...payload },
retries: 0,
})
return null
}
throw err
}
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
queryClient.invalidateQueries({ queryKey: ['message', variables.id] })
},
})
}
export function useDeleteDraft() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
try {
await apiClient.delete(`/mail/drafts/${id}`)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: `draft-delete-${id}-${Date.now()}`,
timestamp: Date.now(),
type: 'delete_draft',
payload: { draft_id: id },
retries: 0,
})
return
}
throw err
}
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ['messages', 'drafts'] })
const previous = queryClient.getQueriesData<PaginatedResponse<ApiOutboxMessage>>({
queryKey: ['messages', 'drafts'],
})
queryClient.setQueriesData<PaginatedResponse<ApiOutboxMessage>>(
{ queryKey: ['messages', 'drafts'] },
(old) => {
if (!old) return old
return { ...old, data: old.data.filter((m) => m.id !== id) }
}
)
return { previous }
},
onError: (_err, _vars, context) => {
context?.previous?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['messages', 'drafts'] })
},
})
}
export function useScheduleSend() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: SendMessagePayload & { scheduled_at: string }) => {
try {
return await apiClient.post<ApiOutboxMessage>('/mail/send', payload)
} catch (err) {
if (err instanceof OfflineError) {
await enqueue({
id: payload.idempotency_key,
timestamp: Date.now(),
type: 'schedule_send',
payload,
retries: 0,
})
return null
}
throw err
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useRescheduleSend() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, scheduled_at }: { id: string; scheduled_at: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/reschedule`, {
scheduled_at,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useCancelScheduled() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/cancel`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
},
})
}
export function useSendNow() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }) => {
return await apiClient.post<ApiOutboxMessage>(`/mail/outbox/${id}/send-now`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['outbox'] })
queryClient.invalidateQueries({ queryKey: ['messages', 'sent'] })
},
})
}