use-canvas-conversations.ts15.2 KBView on GitHub
/**
 * useCanvasConversations Hook
 *
 * Canvas-scoped data fetching hook that replaces useCRMConversations for canvas components.
 * Consumes useCanvasConfiguration as the single source of truth for column state,
 * AOP selection, and customFieldDefinitions — no duplicate resolution logic.
 *
 * Data flow:
 *   canvasesById[canvasId].viewConfig + AopSlice
 *     → useCanvasConfiguration (columns, customFieldDefinitions, selectedAopIds)
 *     → computeBackendSortFromConfig / computeFiltersFromConfig
 *     → trpc.crm.listConversations
 */

import {
  getClientSortColumnsFromConfig,
  matchesEnumFilters,
} from '@/modules/crm/utils/compute-canvas-filters';
import { getCustomFieldsRevision } from '@/modules/crm/utils/custom-fields-revision';
import type { ConversationSummary } from '@/modules/conversations';
import { useMemo, useEffect, useRef, useState } from 'react';
import { useCanvasListVariables } from '@/modules/crm/hooks/use-canvas-list-variables';
import { useTRPCClient } from '@/providers/query-provider';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { HydratedConversation } from '../types';
import { useCedarStore } from '@/modules/store';

const MAX_FETCH_ALL_PAGES = 50;
const MAX_FETCH_ALL_ITEMS = 10_000;

export function useCanvasConversations(
  canvasId: string | null | undefined,
  pinnedConversationIds?: string[],
  options?: { limit?: number; fetchAllPages?: boolean; syncCurrentList?: boolean },
) {
  // Kanban views have no infinite scroll, so they request a larger first page.
  const pageLimit = options?.limit ?? 50;
  const fetchAllPages = options?.fetchAllPages ?? false;
  // `currentConversationList` is the app's ONE list-selection context: j/k keyboard nav reads
  // it, and the chat turns it into the "N Conversations" context chip. A view that is merely
  // displaying conversations — rather than being the list the user is navigating — must opt
  // out, or it silently repoints that shared state at its own rows. The ordered list is
  // returned either way, so an opted-out view renders from the return value instead.
  const syncCurrentList = options?.syncCurrentList ?? true;
  const trpcClient = useTRPCClient();

  // Filter derivation lives in useCanvasListVariables so the metrics bar above the table
  // consumes the identical object — see that hook's header for why sharing matters.
  const { listVariables, filters, filterSortConfig, dateEpoch, hasRelativeFilters } =
    useCanvasListVariables(canvasId, pinnedConversationIds, { limit: pageLimit });

  // Post-fetch store sync — owned here, not by the filter-derivation hook.
  const setConversations = useCedarStore((state) => state.setConversations);
  const setCurrentConversationList = useCedarStore((state) => state.setCurrentConversationList);
  const previousSignatureRef = useRef<string | null>(null);
  // The fetched+sorted ids, in display order. Always maintained locally so callers can render
  // from it directly rather than reading the global list back out of the store.
  const [orderedList, setOrderedList] = useState<ConversationSummary[]>([]);


  // ── Infinite query ──────────────────────────────────────────────────────
  const conversationsQuery = useInfiniteQuery({
    queryKey: [
      ['crm', 'listConversations'],
      listVariables,
      // dateEpoch busts the cache when the day rolls over for relative date filters
      hasRelativeFilters ? dateEpoch : undefined,
    ],
    enabled: !!canvasId,
    queryFn: async ({ pageParam }) => {
      return await trpcClient.crm.listConversations.query({
        ...listVariables,
        cursor: typeof pageParam === 'number' ? pageParam : undefined,
      });
    },
    initialPageParam: undefined as number | undefined,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    getNextPageParam: (lastPage: any) => {
      return lastPage?.nextCursor ?? undefined;
    },
    staleTime: 30 * 1000,
    placeholderData: (previousData) => previousData,
  });
  const hasNextPage = conversationsQuery.hasNextPage ?? false;
  const isFetching = conversationsQuery.isFetching;
  const isFetchingNextPage = conversationsQuery.isFetchingNextPage;
  const fetchNextPage = conversationsQuery.fetchNextPage;
  const loadedPageCount = conversationsQuery.data?.pages.length ?? 0;

  // ── Flatten pages ───────────────────────────────────────────────────────
  const fetchedConversations = useMemo(() => {
    if (!conversationsQuery.data?.pages) return [];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return conversationsQuery.data.pages.flatMap((page: any) => page.conversations || []);
  }, [conversationsQuery.data?.pages]);

  // ── Optimistic enum membership ──────────────────────────────────────────
  // status/priority are filtered SERVER-side, so a page's membership is fixed at fetch time.
  // Editing a deal's stage writes to the store, never to the fetched page, so the row kept its
  // place in the list under a filter that no longer admits it — mark a deal Closed Lost with
  // "Exclude Closed Lost" on and it just sat there. Re-check the two enum filters against the
  // CURRENT value so the row leaves in the same frame as the click; the invalidation in
  // useOptimisticConversationActions then refetches and the server agrees.
  const storeConversations = useCedarStore((state) => state.conversations);
  const rawConversations = useMemo(
    () =>
      fetchedConversations.filter((conv) =>
        matchesEnumFilters(storeConversations[conv.id]?.data.conversation ?? conv, filters),
      ),
    [
      fetchedConversations,
      storeConversations,
      filters.status,
      filters.excludedStatus,
      filters.priority,
      filters.excludedPriority,
    ],
  );

  // ── Sync to Zustand ─────────────────────────────────────────────────────
  useEffect(() => {
    const activeSorts = Object.entries(filterSortConfig)
      .filter(([, col]) => col.sort?.active)
      .map(([id, col]) => ({ id, sort: col.sort }));

    const signature = JSON.stringify({
      ids: rawConversations.map((conv) => conv.id),
      updatedAt: rawConversations.map((conv) => conv.updatedAt),
      customFieldsRevision: rawConversations.map((conv) =>
        getCustomFieldsRevision(conv.customFields),
      ),
      filters,
      activeSorts,
    });

    if (previousSignatureRef.current === signature) return;
    previousSignatureRef.current = signature;

    try {
      const conversationsMap: Record<string, HydratedConversation> = {};
      const conversationIds: string[] = [];

      rawConversations.forEach((conv) => {
        const people =
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          conv.conversationUsers?.map((cu: any) => cu.person).filter(Boolean) || [];
        const company =
          (conv as { primaryCompany?: HydratedConversation['company'] | null }).primaryCompany ??
          conv.conversationCompanies?.[0]?.company ??
          null;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const latestEvent = (conv.events?.[0] as any) || null;

        conversationsMap[conv.id] = {
          conversation: conv,
          people,
          company,
          userTasks: conv.userTasks || [],
          latestEvent,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          scheduledCalendarEvents: (conv as any).scheduledCalendarEvents || [],
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          customFields: (conv as any).customFields || [],
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          ownerUser: (conv as any).ownerUser ?? null,
        };
        conversationIds.push(conv.id);
      });

      // ── Client-side sorting (optimistic only) ─────────────────────────
      const clientSortColumns = getClientSortColumnsFromConfig(filterSortConfig);

      if (clientSortColumns.length > 0) {
        const getActionDueDate = (
          tasks: (typeof conversationsMap)[string]['userTasks'],
          isCurrent: boolean,
        ): number => {
          if (!tasks || tasks.length === 0) return Infinity;

          const now = new Date();
          const endOfToday = new Date(
            now.getFullYear(),
            now.getMonth(),
            now.getDate(),
            23,
            59,
            59,
            999,
          );

          const todoTasks = tasks.filter((t) => t.status === 'todo');
          const relevantTasks = isCurrent
            ? todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) <= endOfToday)
            : todoTasks.filter((t) => t.dueDate && new Date(t.dueDate) > endOfToday);

          if (relevantTasks.length === 0) return Infinity;

          return relevantTasks.reduce((earliest, task) => {
            const taskDate = new Date(task.dueDate!).getTime();
            return taskDate < earliest ? taskDate : earliest;
          }, Infinity);
        };

        conversationIds.sort((aId, bId) => {
          for (const column of clientSortColumns) {
            let comparison = 0;
            const direction = column.sort.direction;
            const customOrder = column.sort.order;

            if (column.id === 'currentTasks') {
              const aTasks = conversationsMap[aId].userTasks || [];
              const bTasks = conversationsMap[bId].userTasks || [];
              const aCount = aTasks.filter((t) => t.status === 'todo').length;
              const bCount = bTasks.filter((t) => t.status === 'todo').length;
              comparison = aCount - bCount;
            } else if (column.id === 'currentAction') {
              const aTasks = conversationsMap[aId].userTasks || [];
              const bTasks = conversationsMap[bId].userTasks || [];
              comparison = getActionDueDate(aTasks, true) - getActionDueDate(bTasks, true);
            } else if (column.id === 'futureAction') {
              const aTasks = conversationsMap[aId].userTasks || [];
              const bTasks = conversationsMap[bId].userTasks || [];
              comparison = getActionDueDate(aTasks, false) - getActionDueDate(bTasks, false);
            } else if (column.id === 'history') {
              const aEvent = conversationsMap[aId].latestEvent;
              const bEvent = conversationsMap[bId].latestEvent;

              const aEventType = aEvent
                ? aEvent.eventType === 'email' && aEvent.direction
                  ? `email_${aEvent.direction}`
                  : aEvent.eventType
                : '';
              const bEventType = bEvent
                ? bEvent.eventType === 'email' && bEvent.direction
                  ? `email_${bEvent.direction}`
                  : bEvent.eventType
                : '';

              if (customOrder && customOrder.length > 0) {
                const aIndex = customOrder.indexOf(aEventType);
                const bIndex = customOrder.indexOf(bEventType);
                comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
              } else {
                comparison = aEventType.localeCompare(bEventType);
              }
            } else if (column.id === 'hasFutureCalendar') {
              const now = new Date();
              const getNextMeetingTime = (id: string): number => {
                // eslint-disable-next-line @typescript-eslint/no-explicit-any
                const events: any[] = conversationsMap[id].scheduledCalendarEvents || [];
                for (const event of events) {
                  const endTime = new Date(event.endTime);
                  if (event.isAllDay || endTime <= now) continue;
                  return new Date(event.startTime).getTime();
                }
                return Infinity;
              };
              const aTime = getNextMeetingTime(aId);
              const bTime = getNextMeetingTime(bId);
              if (aTime === bTime) comparison = 0;
              else if (aTime === Infinity) comparison = 1;
              else if (bTime === Infinity) comparison = -1;
              else comparison = aTime - bTime;
            }

            const result = direction === 'asc' ? comparison : -comparison;
            if (result !== 0) return result;
          }
          return 0;
        });
      }

      // 'list' — these rows come from crm.listConversations, whose events are the overview
      // projection. The flag stops them replacing the full timeline of a conversation that is
      // open. See setConversations in conversationsSlice.
      setConversations(conversationsMap, { projection: 'list' });

      const conversationList: ConversationSummary[] = conversationIds.map((id) => {
        const conv = conversationsMap[id];
        return { id, name: conv.conversation.name || undefined };
      });
      setOrderedList(conversationList);
      if (syncCurrentList) setCurrentConversationList(conversationList);
    } catch (error) {
      console.error('[useCanvasConversations] Failed to update state:', error);
    }
  }, [
    rawConversations,
    filters,
    filterSortConfig,
    setConversations,
    setCurrentConversationList,
    syncCurrentList,
  ]);

  // ── Auto-fetch more when filtered to few results ────────────────────────
  useEffect(() => {
    if (!fetchAllPages) return;
    if (loadedPageCount >= MAX_FETCH_ALL_PAGES) return;
    if (rawConversations.length >= MAX_FETCH_ALL_ITEMS) return;
    if (
      hasNextPage &&
      !isFetching &&
      !isFetchingNextPage
    ) {
      void fetchNextPage();
    }
  }, [
    fetchAllPages,
    loadedPageCount,
    rawConversations.length,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    fetchNextPage,
  ]);

  useEffect(() => {
    if (fetchAllPages) return;
    // Our own ordered list when we don't own the global one — reading the store there would
    // measure some other view's rows.
    const visibleCount = syncCurrentList
      ? useCedarStore.getState().getCurrentConversationList().length
      : orderedList.length;

    if (
      visibleCount < 20 &&
      hasNextPage &&
      !isFetching &&
      !isFetchingNextPage
    ) {
      void fetchNextPage();
    }
  }, [
    fetchAllPages,
    rawConversations.length,
    filters,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    fetchNextPage,
    syncCurrentList,
    orderedList.length,
  ]);

  // ── Load more ───────────────────────────────────────────────────────────
  const loadMore = async () => {
    if (
      conversationsQuery.isLoading ||
      conversationsQuery.isFetching ||
      conversationsQuery.isFetchingNextPage
    ) {
      return;
    }

    if (!conversationsQuery.hasNextPage) return;

    await conversationsQuery.fetchNextPage();
  };

  return [conversationsQuery, conversationsQuery.hasNextPage ?? false, loadMore, orderedList] as const;
}