Adds a "Scan for sellers" button to the conversations header, backed by POST /api/conversations/seller-scan. The sweep reuses the existing seller detection rather than inventing a second one: the configured keyword/domain list, the seller-link funnel patterns, and the aiReplyScreening confidences all decide the verdict, so a swept conversation lands in the state the live reply loop would have given it. Being operator-forced, it deliberately ignores filterSellers and aiReplyScreening.enabled. AI is the last resort. Conversations are skipped when already flagged or killed, settled for free by the deterministic rules, or skipped against a durable sellerScreenedKey watermark when nothing new has arrived since the last clean sweep. Only the remainder reaches the model, batched eight at a time through a new screenConversationsForSellers, so the system prompt and round trip are paid per batch instead of per conversation. A classifier outage degrades the sweep instead of failing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
import type { ActiveChat, ConversationTriageStatus } from '../../../../../packages/api-contracts/src/index.js';
|
|
import { conversationApi } from './conversation-api.js';
|
|
import { ownedConversationId } from './conversation-identity.js';
|
|
|
|
export const conversationQueryKeys = {
|
|
all: ['conversations'] as const,
|
|
detail: (id: string) => ['conversations', 'detail', id] as const,
|
|
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
|
|
mediaShareOptions: (id: string) => ['conversations', 'media-share-options', id] as const,
|
|
};
|
|
|
|
export const CONVERSATION_REFRESH_INTERVAL_MS = 30_000;
|
|
|
|
export function useConversationsQuery() {
|
|
return useQuery({
|
|
queryKey: conversationQueryKeys.all,
|
|
queryFn: conversationApi.list,
|
|
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
|
});
|
|
}
|
|
|
|
export function useConversationDetailQuery(id: string | null) {
|
|
return useQuery({
|
|
queryKey: conversationQueryKeys.detail(id ?? ''),
|
|
queryFn: () => conversationApi.detail(id ?? ''),
|
|
enabled: id !== null,
|
|
refetchInterval: CONVERSATION_REFRESH_INTERVAL_MS,
|
|
});
|
|
}
|
|
|
|
export function useSuggestReplyMutation(id: string) {
|
|
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
|
|
}
|
|
|
|
export function useSuggestMediaAskMutation(id: string) {
|
|
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
|
|
}
|
|
|
|
export function useConversationMediaShareOptionsQuery(id: string, enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: conversationQueryKeys.mediaShareOptions(id),
|
|
queryFn: () => conversationApi.mediaShareOptions(id),
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
export function useProcessedPostsQuery(username: string) {
|
|
return useQuery({
|
|
queryKey: conversationQueryKeys.processedPosts(username),
|
|
queryFn: () => conversationApi.processedPosts(username),
|
|
enabled: username.length > 0,
|
|
});
|
|
}
|
|
|
|
export function useSendReplyMutation(id: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (message: string) => conversationApi.sendReply(id, message),
|
|
async onSuccess() {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
|
|
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
|
|
]);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useShareConversationMediaMutation(id: string) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (assetId: string) => conversationApi.shareMedia(id, assetId),
|
|
async onSuccess() {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all }),
|
|
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.detail(id) }),
|
|
queryClient.invalidateQueries({ queryKey: conversationQueryKeys.mediaShareOptions(id) }),
|
|
]);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Applies a triage patch to the cached list and detail entries for one
|
|
* conversation. Read-state writes are optimistic because the list only polls
|
|
* every 30s — waiting for a refetch would make marking read feel broken.
|
|
*/
|
|
function patchCachedConversation(
|
|
queryClient: QueryClient,
|
|
id: string,
|
|
patch: Partial<ActiveChat>,
|
|
): void {
|
|
queryClient.setQueryData<ActiveChat[]>(conversationQueryKeys.all, (current) =>
|
|
current?.map((conversation) => (ownedConversationId(conversation) === id
|
|
? { ...conversation, ...patch }
|
|
: conversation)));
|
|
queryClient.setQueryData<ActiveChat>(
|
|
conversationQueryKeys.detail(id),
|
|
(current) => (current ? { ...current, ...patch } : current),
|
|
);
|
|
}
|
|
|
|
function cachedConversation(queryClient: QueryClient, id: string): ActiveChat | undefined {
|
|
return queryClient.getQueryData<ActiveChat[]>(conversationQueryKeys.all)
|
|
?.find((conversation) => ownedConversationId(conversation) === id);
|
|
}
|
|
|
|
export function useMarkConversationReadMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, throughTimestamp }: { id: string; throughTimestamp?: number }) =>
|
|
conversationApi.markRead(id, throughTimestamp),
|
|
onMutate({ id, throughTimestamp }) {
|
|
const watermark = throughTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
|
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
|
|
},
|
|
async onSettled() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useMarkConversationUnreadMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, beforeTimestamp }: { id: string; beforeTimestamp?: number }) =>
|
|
conversationApi.markUnread(id, beforeTimestamp),
|
|
onMutate({ id, beforeTimestamp }) {
|
|
const boundary = beforeTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
|
patchCachedConversation(queryClient, id, {
|
|
hasUnreadInbound: true,
|
|
triageStatus: 'open',
|
|
snoozedUntil: null,
|
|
lastReadMessageTs: Math.max(0, boundary - 1),
|
|
});
|
|
},
|
|
async onSettled() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSetConversationStatusMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, status, snoozeUntil }: {
|
|
id: string;
|
|
status: ConversationTriageStatus;
|
|
snoozeUntil?: number | null;
|
|
}) => conversationApi.setStatus(id, status, snoozeUntil),
|
|
onMutate({ id, status, snoozeUntil }) {
|
|
const conversation = cachedConversation(queryClient, id);
|
|
patchCachedConversation(queryClient, id, {
|
|
triageStatus: status,
|
|
snoozedUntil: status === 'snoozed' ? snoozeUntil ?? null : null,
|
|
// Resolving a conversation implies you have read it.
|
|
...(status === 'done'
|
|
? { hasUnreadInbound: false, lastReadMessageTs: conversation?.lastMessageTime ?? 0 }
|
|
: {}),
|
|
needsReply: status === 'open' && conversation?.isFromUs === false,
|
|
});
|
|
},
|
|
async onSettled() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useBulkMarkConversationsReadMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (ids: string[]) => conversationApi.bulkMarkRead(ids),
|
|
onMutate(ids) {
|
|
for (const id of ids) {
|
|
const watermark = cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
|
|
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
|
|
}
|
|
},
|
|
async onSettled() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Forces a seller pass over every active conversation. The list is refetched
|
|
* on completion because a sweep can flag, kill and resolve conversations.
|
|
*/
|
|
export function useSellerScanMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () => conversationApi.sellerScan(),
|
|
async onSuccess() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useStartConversationMutation() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ accountId, username, message }: { accountId: string; username: string; message: string }) =>
|
|
conversationApi.startConversation(accountId, username, message),
|
|
async onSuccess() {
|
|
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
|
|
},
|
|
});
|
|
}
|