Files
RedditBot/apps/dashboard/src/features/conversations/conversation-queries.ts
T
Jamie BruntonandClaude Opus 5 827eb3a1e0 feat: force a seller scan over active conversations from the inbox
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>
2026-08-10 13:42:55 +01:00

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 });
},
});
}