use-crm-conversations.ts38.2 KBView on GitHub
/**
 * useCRMConversations Hook
 *
 * Handles infinite scroll for CRM conversations with cursor-based pagination.
 * Similar to useThreads, but for CRM conversations.
 *
 * Features:
 * - Uses useInfiniteQuery for cursor-based pagination
 * - Syncs to Zustand stores (conversationsSlice + crmSlice)
 * - Supports client-side multi-select filtering
 * - Auto-fetches more when filtered down to few results
 * - Returns loadMore function for manual triggering
 */

import type { CanvasFilterSortConfiguration } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { ConversationSummary } from '@/modules/conversations';
import {
  computeFiltersFromConfig,
  computeBackendSortFromConfig,
  getClientSortColumnsFromConfig,
  type CustomFieldFilterEntry,
} from '../utils/compute-canvas-filters';
import { useTRPCClient } from '@/providers/query-provider';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import type { HydratedConversation } from '../types';
import { useMemo, useEffect, useRef, useState } from 'react';
import { getCustomFieldsRevision } from '@/modules/crm/utils/custom-fields-revision';
import { normalizeSortDimension } from '@/modules/crm/store/crmSlice';
import { useCedarStore } from '@/modules/store';

/**
 * Optional canvas viewConfig overrides that bypass the global CRMSlice.
 * When provided, filters/sorts/selectedAopIds come from viewConfig instead of Zustand.
 */
export interface CRMConversationsOverrides {
  selectedAopIds?: (string | null)[] | null;
  filterSortConfiguration?: CanvasFilterSortConfiguration;
  searchQuery?: string;
}

export function useCRMConversations(
  pinnedConversationIds?: string[],
  viewConfigOverrides?: CRMConversationsOverrides,
) {
  const trpcClient = useTRPCClient();

  // Always read from store (hooks called unconditionally)
  const searchQueryFromStore = useCedarStore((state) => state.searchQuery);
  const getFilters = useCedarStore((state) => state.getFilters);
  const columns = useCedarStore((state) => state.columns);
  const setConversations = useCedarStore((state) => state.setConversations);
  const setCurrentConversationList = useCedarStore((state) => state.setCurrentConversationList);
  const previousSignatureRef = useRef<string | null>(null);
  const { data: aopsData } = useAOPs();
  const aops = aopsData?.aops;
  const aopsById = useCedarStore((state) => state.aopsById);

  // Get selectedAopIds — from viewConfig override or store
  const selectedAopIdsFromStore = useCedarStore((state) => state.selectedAopIds);
  const isOverrideMode = viewConfigOverrides !== undefined;

  const selectedAopIds = isOverrideMode
    ? viewConfigOverrides.selectedAopIds
    : selectedAopIdsFromStore;

  // Resolve customFieldDefinitions from the single selected AOP (for custom field sort/filter)
  const customFieldDefinitions = useMemo(() => {
    const realIds = (selectedAopIds ?? []).filter((id): id is string => id !== null);
    if (realIds.length !== 1) return undefined;
    return aopsById[realIds[0]]?.customFieldDefinitions ?? undefined;
  }, [selectedAopIds, aopsById]);

  // ── Date epoch for relative filter cache invalidation ─────────────
  // When any filter uses dateRelative, we need to re-resolve offsets when the day rolls over.
  // A 60-second interval checks for day changes and triggers re-render + cache invalidation.
  const hasRelativeFilters = useMemo(() => {
    if (isOverrideMode) {
      return Object.values(viewConfigOverrides?.filterSortConfiguration ?? {}).some(
        (col) => col.filter?.dateRelative,
      );
    }
    return Object.values(columns).some((col) => col.filter?.dateRelative);
  }, [isOverrideMode, viewConfigOverrides?.filterSortConfiguration, columns]);
  const [dateEpoch, setDateEpoch] = useState(() => new Date().toDateString());
  useEffect(() => {
    if (!hasRelativeFilters) return;
    const interval = setInterval(() => {
      const today = new Date().toDateString();
      setDateEpoch((prev) => (prev !== today ? today : prev));
    }, 60_000);
    return () => clearInterval(interval);
  }, [hasRelativeFilters]);

  // Compute filters from viewConfig override or CRMSlice
  const searchQuery = isOverrideMode ? viewConfigOverrides.searchQuery : searchQueryFromStore;
  const overrideResult = isOverrideMode
    ? computeFiltersFromConfig(
        viewConfigOverrides.filterSortConfiguration ?? {},
        searchQuery,
        customFieldDefinitions,
      )
    : null;
  const filters = overrideResult ? overrideResult.filters : getFilters();

  // Use selectedAopIds directly for backend filtering
  // null = first load (auto-select will happen), [] = show all, [ids] = filter by these
  const backendAopIds = useMemo(() => {
    // null or empty array means show all
    if (!selectedAopIds || selectedAopIds.length === 0) return undefined;
    return selectedAopIds;
  }, [selectedAopIds]);

  // Build nested filter objects for enum columns (include/exclude in one object)
  const backendStatus = useMemo(() => {
    const hasInclude = filters.status && filters.status.length > 0;
    const hasExclude = filters.excludedStatus && filters.excludedStatus.length > 0;
    if (!hasInclude && !hasExclude) return undefined;
    return {
      include: hasInclude ? filters.status : undefined,
      exclude: hasExclude ? filters.excludedStatus : undefined,
    };
  }, [filters.status, filters.excludedStatus]);

  const backendPriority = useMemo(() => {
    const hasInclude = filters.priority && filters.priority.length > 0;
    const hasExclude = filters.excludedPriority && filters.excludedPriority.length > 0;
    if (!hasInclude && !hasExclude) return undefined;
    return {
      include: hasInclude ? filters.priority : undefined,
      exclude: hasExclude ? filters.excludedPriority : undefined,
    };
  }, [filters.priority, filters.excludedPriority]);

  // Date filters - send date only if operator is not 'empty'
  const backendNextStepDate = useMemo(() => {
    if (filters.nextStepDate?.operator === 'empty') return undefined;
    return filters.nextStepDate?.date;
  }, [filters.nextStepDate?.date, filters.nextStepDate?.operator]);

  const backendNextStepDateOperator = useMemo(() => {
    return filters.nextStepDate?.operator;
  }, [filters.nextStepDate?.operator]);

  const backendNextStepDateTo = useMemo(() => {
    return filters.nextStepDate?.dateTo;
  }, [filters.nextStepDate?.dateTo]);

  const backendLastContactDate = useMemo(() => {
    if (filters.lastContact?.operator === 'empty') return undefined;
    return filters.lastContact?.date;
  }, [filters.lastContact?.date, filters.lastContact?.operator]);

  const backendLastContactDateOperator = useMemo(() => {
    return filters.lastContact?.operator;
  }, [filters.lastContact?.operator]);

  const backendLastContactDateTo = useMemo(() => {
    return filters.lastContact?.dateTo;
  }, [filters.lastContact?.dateTo]);

  const backendLastMeetingDate = useMemo(() => {
    if (filters.lastMeeting?.operator === 'empty') return undefined;
    return filters.lastMeeting?.date;
  }, [filters.lastMeeting?.date, filters.lastMeeting?.operator]);

  const backendLastMeetingDateOperator = useMemo(() => {
    return filters.lastMeeting?.operator;
  }, [filters.lastMeeting?.operator]);

  const backendLastMeetingDateTo = useMemo(() => {
    return filters.lastMeeting?.dateTo;
  }, [filters.lastMeeting?.dateTo]);

  // Number filter - deal value
  const backendDealValue = useMemo(() => {
    if (filters.dealValue?.operator === 'empty') return undefined;
    if (!filters.dealValue?.value) return undefined;
    const num = Number(filters.dealValue.value);
    return isNaN(num) ? undefined : num;
  }, [filters.dealValue?.value, filters.dealValue?.operator]);

  const backendDealValueOperator = useMemo(() => {
    return filters.dealValue?.operator;
  }, [filters.dealValue?.operator]);

  // Boolean filters
  const backendCrmSynced = useMemo(() => {
    return filters.crmSynced;
  }, [filters.crmSynced]);

  const backendHasFutureCalendar = useMemo(() => {
    return filters.hasFutureCalendar;
  }, [filters.hasFutureCalendar]);

  const backendLastEventByTypeDate = useMemo(() => {
    if (filters.lastEventByType?.operator === 'empty') return undefined;
    return filters.lastEventByType?.date;
  }, [filters.lastEventByType]);
  const backendLastEventByTypeDateOperator = useMemo(
    () => filters.lastEventByType?.operator,
    [filters.lastEventByType?.operator],
  );
  const backendLastEventByTypeDateTo = useMemo(
    () => filters.lastEventByType?.dateTo,
    [filters.lastEventByType?.dateTo],
  );
  const backendLastEventByTypeTypes = useMemo(
    () => (filters.lastEventByType as { types?: string[] } | undefined)?.types,
    [filters.lastEventByType],
  );

  const backendHasTodoTasks = useMemo(() => {
    return filters.hasTodoTasks;
  }, [filters.hasTodoTasks]);

  const backendTaskTypes = useMemo(() => filters.taskTypes, [filters.taskTypes]);
  const backendTaskDueDate = useMemo(() => {
    if (filters.taskDueDate?.operator === 'empty') return undefined;
    return filters.taskDueDate?.date;
  }, [filters.taskDueDate]);
  const backendTaskDueDateOperator = useMemo(
    () => filters.taskDueDate?.operator,
    [filters.taskDueDate?.operator],
  );
  const backendTaskDueDateTo = useMemo(
    () => filters.taskDueDate?.dateTo,
    [filters.taskDueDate?.dateTo],
  );

  // Current action filter - tasks with due date constraint
  const backendCurrentActionHasTasks = useMemo(() => {
    return filters.currentActionHasTasks;
  }, [filters.currentActionHasTasks]);

  const backendCurrentActionDueBefore = useMemo(() => {
    return filters.currentActionDueBefore;
  }, [filters.currentActionDueBefore]);

  const backendCurrentActionTaskTypes = useMemo(() => {
    return filters.currentActionTaskTypes;
  }, [filters.currentActionTaskTypes]);

  // Event filter - combines event types with optional date constraint
  const backendEventFilter = useMemo(() => {
    const hasInclude = filters.eventTypes && filters.eventTypes.length > 0;
    const hasExclude = filters.excludedEventTypes && filters.excludedEventTypes.length > 0;
    // Only support before/after/on/range for events (not 'empty' - it doesn't make sense for events)
    const eventDateOperator =
      filters.eventDate?.operator && filters.eventDate.operator !== 'empty'
        ? (filters.eventDate.operator as 'before' | 'after' | 'on' | 'range')
        : undefined;
    const hasEventDate = filters.eventDate?.date && eventDateOperator;

    if (!hasInclude && !hasExclude && !hasEventDate) return undefined;

    return {
      types:
        hasInclude || hasExclude
          ? {
              include: hasInclude ? filters.eventTypes : undefined,
              exclude: hasExclude ? filters.excludedEventTypes : undefined,
            }
          : undefined,
      date: hasEventDate ? filters.eventDate?.date : undefined,
      dateTo: hasEventDate && eventDateOperator === 'range' ? filters.eventDate?.dateTo : undefined,
      dateOperator: eventDateOperator,
    };
  }, [filters.eventTypes, filters.excludedEventTypes, filters.eventDate]);

  // Latest event filter — filter by type/date of the most recent event per conversation
  const backendLatestEventFilter = useMemo(() => {
    const hasTypes = filters.latestEventType && filters.latestEventType.length > 0;
    const latestDateOperator =
      filters.latestEventDate?.operator && filters.latestEventDate.operator !== 'empty'
        ? (filters.latestEventDate.operator as 'before' | 'after' | 'on' | 'range')
        : undefined;
    const hasDate = filters.latestEventDate?.date && latestDateOperator;
    if (!hasTypes && !hasDate) return undefined;
    return {
      types: hasTypes ? filters.latestEventType : undefined,
      date: hasDate ? filters.latestEventDate?.date : undefined,
      dateTo: hasDate && latestDateOperator === 'range' ? filters.latestEventDate?.dateTo : undefined,
      dateOperator: latestDateOperator,
    };
  }, [filters.latestEventType, filters.latestEventDate]);

  // Build sort configuration for backend
  const backendSortBy = useMemo(() => {
    if (isOverrideMode) {
      return computeBackendSortFromConfig(
        viewConfigOverrides?.filterSortConfiguration ?? {},
        aops,
        customFieldDefinitions,
      );
    }

    // Non-override mode: convert CRMSlice columns to the discriminated-union backend format
    // Mirrors computeBackendSortFromConfig but reads from Zustand columns directly.
    const NON_VALUE_SORTABLE_TYPES = new Set(['text', 'url', 'email', 'phone']);

    type BuiltInField =
      | 'lastContactedAt'
      | 'nextStepDate'
      | 'dealValue'
      | 'name'
      | 'status'
      | 'priority'
      | 'aopId'
      | 'lastMeetingDate';

    const fieldMap: Record<string, BuiltInField> = {
      lastContactedAt: 'lastContactedAt',
      nextSteps: 'nextStepDate',
      dealValue: 'dealValue',
      name: 'name',
      status: 'status',
      priority: 'priority',
      type: 'aopId',
      lastMeetingTime: 'lastMeetingDate',
    };

    const sortedColumns = Object.values(columns)
      .filter((col) => col.sort?.active)
      .sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));

    if (sortedColumns.length === 0) return undefined;

    const result = sortedColumns
      .map((col) => {
        const { id, sort } = col;
        if (!sort) return null;

        if (
          id === 'currentTasks' ||
          id === 'history' ||
          id === 'lastOutboundEmailTime' ||
          id === 'hasFutureCalendar'
        ) return null;

        // Custom field columns
        if (id.startsWith('wm_')) {
          const fieldKey=[redacted];
          const fieldDef = customFieldDefinitions?.[fieldKey];
          if (!fieldDef) return null;

          const sortDimension = normalizeSortDimension(sort.sortDimension) ?? 'value';
          if (sortDimension === 'value' && NON_VALUE_SORTABLE_TYPES.has(fieldDef.type)) return null;

          return {
            kind: 'customField' as const,
            fieldKey,
            fieldType: fieldDef.type,
            sortDimension,
            direction: sort.direction,
            customOrder: sort.order && sort.order.length > 0 ? sort.order : undefined,
          };
        }

        // Built-in columns
        const field = fieldMap[id];
        if (!field) return null;

        let customOrder = sort.order;
        if (id === 'type' && customOrder && customOrder.length > 0 && aops) {
          customOrder = customOrder.map((name) => {
            if (name === null) return null;
            return aops.find((a) => a.name === name)?.id ?? null;
          });
        }

        return {
          kind: 'builtin' as const,
          field,
          direction: sort.direction,
          customOrder: customOrder && customOrder.length > 0 ? customOrder : undefined,
        };
      })
      .filter((s): s is NonNullable<typeof s> => s !== null);

    return result.length > 0 ? result : undefined;
  }, [isOverrideMode, viewConfigOverrides?.filterSortConfiguration, columns, aops, customFieldDefinitions]);

  // Build custom field filter array for backend
  const backendCustomFieldFilters = useMemo((): CustomFieldFilterEntry[] | undefined => {
    if (isOverrideMode) {
      const result = overrideResult?.customFieldFilters;
      return result && result.length > 0 ? result : undefined;
    }

    // Non-override mode: extract wm_* columns with active filters from CRMSlice
    if (!customFieldDefinitions) return undefined;

    const result: CustomFieldFilterEntry[] = [];
    for (const col of Object.values(columns)) {
      if (!col.id.startsWith('wm_') || !col.filter) continue;

      const fieldKey=[redacted];
      const fieldDef = customFieldDefinitions[fieldKey];
      if (!fieldDef) continue;

      const filter = col.filter;
      const filterDimension = filter.filterDimension ?? 'value';
      const base = { fieldKey, fieldType: fieldDef.type, filterDimension };

      // Signal dimension → red/yellow/green select filter
      if (filterDimension === 'signal') {
        if (
          (filter.selected && filter.selected.length > 0) ||
          (filter.excluded && filter.excluded.length > 0)
        ) {
          result.push({ ...base, kind: 'select', include: filter.selected, exclude: filter.excluded });
        }
        continue;
      }

      // Value dimension — kind by fieldType
      switch (fieldDef.type) {
        case 'select':
          if ((filter.selected && filter.selected.length > 0) || (filter.excluded && filter.excluded.length > 0)) {
            result.push({ ...base, kind: 'select', include: filter.selected, exclude: filter.excluded });
          }
          break;
        case 'number':
        case 'currency':
          if (filter.numberOperator) {
            result.push({ ...base, kind: 'number', operator: filter.numberOperator, value: filter.numberValue });
          }
          break;
        case 'date':
          if (filter.dateOperator) {
            result.push({
              ...base,
              kind: 'date',
              operator: filter.dateOperator as NonNullable<CustomFieldFilterEntry['operator']>,
              date: filter.dateFrom,
              dateTo: filter.dateTo,
            });
          }
          break;
        case 'text':
        case 'url':
        case 'email':
        case 'phone':
          if (filter.searchText || filter.selected?.includes(null)) {
            result.push({
              ...base,
              kind: 'text',
              contains: filter.searchText,
              empty: filter.selected?.includes(null) ?? undefined,
            });
          }
          break;
        case 'boolean':
          if (filter.binaryChoice) {
            result.push({ ...base, kind: 'boolean', booleanValue: filter.binaryChoice as 'true' | 'false' });
          } else if (filter.selected?.includes(null)) {
            result.push({ ...base, kind: 'boolean', booleanValue: 'empty' });
          }
          break;
      }
    }

    return result.length > 0 ? result : undefined;
  }, [isOverrideMode, overrideResult, columns, customFieldDefinitions]);

  // When canvas is pinned to specific IDs (e.g. Finding Conversations results), request only those IDs.
  // No other filters are sent so the backend applies a single condition (id IN (...)); no special-case logic required.
  const isPinnedToList = Boolean(
    pinnedConversationIds && pinnedConversationIds.length > 0,
  );

  const listVariables = useMemo(() => {
    if (isPinnedToList) {
      return {
        conversationIds: pinnedConversationIds,
        limit: 50 as const,
        search: undefined,
        aopIds: undefined,
        status: undefined,
        priority: undefined,
        nextStepDate: undefined,
        nextStepDateOperator: undefined,
        nextStepDateTo: undefined,
        lastContactDate: undefined,
        lastContactDateOperator: undefined,
        lastContactDateTo: undefined,
        lastMeetingDate: undefined,
        lastMeetingDateOperator: undefined,
        dealValue: undefined,
        dealValueOperator: undefined,
        crmSynced: undefined,
        hasFutureCalendar: undefined,
        lastEventByTypeDate: undefined,
        lastEventByTypeDateOperator: undefined,
        lastEventByTypeDateTo: undefined,
        lastEventByTypeTypes: undefined,
        hasTodoTasks: undefined,
        taskTypes: undefined,
        taskDueDate: undefined,
        taskDueDateOperator: undefined,
        taskDueDateTo: undefined,
        currentActionHasTasks: undefined,
        currentActionDueBefore: undefined,
        currentActionTaskTypes: undefined,
        eventFilter: undefined,
        latestEventFilter: undefined,
        sortBy: undefined,
        customFieldFilters: undefined,
      };
    }
    return {
      search: searchQuery,
      aopIds: backendAopIds,
      status: backendStatus,
      priority: backendPriority,
      nextStepDate: backendNextStepDate,
      nextStepDateOperator: backendNextStepDateOperator,
      nextStepDateTo: backendNextStepDateTo,
      lastContactDate: backendLastContactDate,
      lastContactDateOperator: backendLastContactDateOperator,
      lastContactDateTo: backendLastContactDateTo,
      lastMeetingDate: backendLastMeetingDate,
      lastMeetingDateOperator: backendLastMeetingDateOperator,
      lastMeetingDateTo: backendLastMeetingDateTo,
      dealValue: backendDealValue,
      dealValueOperator: backendDealValueOperator,
      crmSynced: backendCrmSynced,
      hasFutureCalendar: backendHasFutureCalendar,
      lastEventByTypeDate: backendLastEventByTypeDate,
      lastEventByTypeDateOperator: backendLastEventByTypeDateOperator,
      lastEventByTypeDateTo: backendLastEventByTypeDateTo,
      lastEventByTypeTypes: backendLastEventByTypeTypes,
      hasTodoTasks: backendHasTodoTasks,
      taskTypes: backendTaskTypes,
      taskDueDate: backendTaskDueDate,
      taskDueDateOperator: backendTaskDueDateOperator,
      taskDueDateTo: backendTaskDueDateTo,
      currentActionHasTasks: backendCurrentActionHasTasks,
      currentActionDueBefore: backendCurrentActionDueBefore,
      currentActionTaskTypes: backendCurrentActionTaskTypes,
      eventFilter: backendEventFilter,
      latestEventFilter: backendLatestEventFilter,
      sortBy: backendSortBy,
      customFieldFilters: backendCustomFieldFilters,
      limit: 50 as const,
      conversationIds: undefined,
    };
  }, [
    isPinnedToList,
    pinnedConversationIds,
    searchQuery,
    backendAopIds,
    backendStatus,
    backendPriority,
    backendNextStepDate,
    backendNextStepDateOperator,
    backendNextStepDateTo,
    backendLastContactDate,
    backendLastContactDateOperator,
    backendLastContactDateTo,
    backendLastMeetingDate,
    backendLastMeetingDateOperator,
    backendLastMeetingDateTo,
    backendDealValue,
    backendDealValueOperator,
    backendCrmSynced,
    backendHasFutureCalendar,
    backendLastEventByTypeDate,
    backendLastEventByTypeDateOperator,
    backendLastEventByTypeDateTo,
    backendLastEventByTypeTypes,
    backendHasTodoTasks,
    backendTaskTypes,
    backendTaskDueDate,
    backendTaskDueDateOperator,
    backendTaskDueDateTo,
    backendCurrentActionHasTasks,
    backendCurrentActionDueBefore,
    backendEventFilter,
    backendLatestEventFilter,
    backendSortBy,
    backendCustomFieldFilters,
  ]);

  // Setup infinite query with cursor-based pagination
  // All filters are now handled by the backend
  const conversationsQuery = useInfiniteQuery({
    queryKey: [
      ['crm', 'listConversations'],
      listVariables,
      // dateEpoch busts the cache when the day rolls over for relative date filters
      hasRelativeFilters ? dateEpoch : undefined,
    ],
    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 offset for next page, or undefined if no more pages
      return lastPage?.nextCursor ?? undefined;
    },
    staleTime: 30 * 1000, // 30 seconds
    placeholderData: (previousData) => previousData,
  });

  // Flatten all pages into single conversation array
  const rawConversations = 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]);

  // Sync to Zustand when data arrives
  useEffect(() => {
    // Create signature to detect actual changes
    // Extract active sorts — from canvas override config when in override mode, else from columns
    const activeSorts = isOverrideMode
      ? Object.entries(viewConfigOverrides?.filterSortConfiguration ?? {})
          .filter(([, col]) => col.sort?.active)
          .map(([id, col]) => ({ id, sort: col.sort }))
      : Object.values(columns)
          .filter((col) => col.sort?.active)
          .map((col) => ({ 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: filters,
      activeSorts: activeSorts,
    });

    // Skip if data hasn't changed
    if (previousSignatureRef.current === signature) return;
    previousSignatureRef.current = signature;

    try {
      // Transform to Record<conversationId, HydratedConversation>
      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) || [];
        // Use primary company from backend (resolved via primary_company_id)
        // Falls back to conversationCompanies[0] for backward compatibility
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const company = (conv as any).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 || [],
        };
        conversationIds.push(conv.id);
      });

      // ============================================
      // CLIENT-SIDE FILTERING
      // ============================================
      // All filters are now handled by the backend.
      // No client-side filtering needed.

      // ============================================
      // SORTING
      // ============================================
      // Sorting is mostly handled by the BACKEND for better performance.
      // Exceptions: currentTasks, currentAction, futureAction, lastMeetingTime, lastOutboundEmailTime
      // are sorted client-side since the data is already loaded in memory.

      // Get all client-side sortable columns
      const CLIENT_SORT_COLUMNS = new Set([
        'currentTasks',
        'currentAction',
        'futureAction',
        'lastOutboundEmailTime',
        'hasFutureCalendar',
      ]);
      const clientSortColumns = isOverrideMode
        ? getClientSortColumnsFromConfig(viewConfigOverrides?.filterSortConfiguration ?? {})
        : Object.values(columns)
            .filter((col) => CLIENT_SORT_COLUMNS.has(col.id) && col.sort?.active)
            .sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));

      if (clientSortColumns.length > 0) {
        // Helper to get the earliest due date for current/future action sorting
        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;

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

          return earliestDueDate;
        };

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

            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') {
              // Sort by earliest overdue/current task due date
              const aTasks = conversationsMap[aId].userTasks || [];
              const bTasks = conversationsMap[bId].userTasks || [];
              const aDueDate = getActionDueDate(aTasks, true);
              const bDueDate = getActionDueDate(bTasks, true);
              comparison = aDueDate - bDueDate;
            } else if (column.id === 'futureAction') {
              // Sort by earliest future task due date
              const aTasks = conversationsMap[aId].userTasks || [];
              const bTasks = conversationsMap[bId].userTasks || [];
              const aDueDate = getActionDueDate(aTasks, false);
              const bDueDate = getActionDueDate(bTasks, false);
              comparison = aDueDate - bDueDate;
            } else if (column.id === 'lastOutboundEmailTime') {
              // The scalar, not a scan of `events` — that array is capped, so this optimistic
              // sort used to disagree with the server's ORDER BY, which has always computed
              // MAX(occurred_at) over the full history.
              const latestOutbound = (id: string) => {
                const at = conversationsMap[id].conversation.lastOutboundEmailAt;
                return at ? new Date(at).getTime() : 0;
              };
              comparison = latestOutbound(aId) - latestOutbound(bId);
            } 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;
            }

            // Apply sort direction
            const result = direction === 'asc' ? comparison : -comparison;

            // If this field produces a non-zero result, use it
            if (result !== 0) {
              return result;
            }
          }

          // All sort fields were equal
          return 0;
        });
      }

      // const sortedColumns = Object.values(columns)
      //   .filter((col) => col.sort?.active)
      //   .sort((a, b) => (a.sort!.priority || 0) - (b.sort!.priority || 0));

      // if (sortedColumns.length > 0) {
      //   conversationIds.sort((aId, bId) => {
      //     const aConv = conversationsMap[aId].conversation;
      //     const bConv = conversationsMap[bId].conversation;

      //     // Apply each sorted column in priority order
      //     for (const column of sortedColumns) {
      //       let comparison = 0;
      //       const columnId = column.id;
      //       const sortDirection = column.sort!.direction;
      //       const customOrder = column.sort!.order;

      //       switch (columnId) {
      //         case 'type': {
      //           const aopA = aops?.find((a) => a.id === aConv.aopId);
      //           const aopB = aops?.find((a) => a.id === bConv.aopId);
      //           const aType = aopA?.name || '';
      //           const bType = aopB?.name || '';
      //           // Use custom order if available
      //           if (customOrder && customOrder.length > 0) {
      //             const aIndex = customOrder.indexOf(aType);
      //             const bIndex = customOrder.indexOf(bType);
      //             comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
      //           } else {
      //             comparison = aType.localeCompare(bType);
      //           }
      //           break;
      //         }
      //         case 'status': {
      //           const aStatus = aConv.status || '';
      //           const bStatus = bConv.status || '';
      //           // Use custom order if available
      //           if (customOrder && customOrder.length > 0) {
      //             const aIndex = customOrder.indexOf(aStatus);
      //             const bIndex = customOrder.indexOf(bStatus);
      //             comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
      //           } else {
      //             comparison = aStatus.localeCompare(bStatus);
      //           }
      //           break;
      //         }
      //         case 'priority': {
      //           const aPriority = aConv.priority || '';
      //           const bPriority = bConv.priority || '';
      //           // Use custom order if available
      //           if (customOrder && customOrder.length > 0) {
      //             const aIndex = customOrder.indexOf(aPriority);
      //             const bIndex = customOrder.indexOf(bPriority);
      //             comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
      //           } else {
      //             comparison = aPriority.localeCompare(bPriority);
      //           }
      //           break;
      //         }
      //         case 'responsiveness': {
      //           const aResponsiveness = aConv.responsiveness || 'unknown';
      //           const bResponsiveness = bConv.responsiveness || 'unknown';
      //           // Use custom order if available
      //           if (customOrder && customOrder.length > 0) {
      //             const aIndex = customOrder.indexOf(aResponsiveness);
      //             const bIndex = customOrder.indexOf(bResponsiveness);
      //             comparison = (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
      //           } else {
      //             comparison = aResponsiveness.localeCompare(bResponsiveness);
      //           }
      //           break;
      //         }
      //         case 'lastContactedAt': {
      //           const aDate = aConv.lastContactedAt ? new Date(aConv.lastContactedAt).getTime() : 0;
      //           const bDate = bConv.lastContactedAt ? new Date(bConv.lastContactedAt).getTime() : 0;
      //           comparison = aDate - bDate;
      //           break;
      //         }
      //         case 'dealValue': {
      //           const aDealValue = aConv.dealValue || 0;
      //           const bDealValue = bConv.dealValue || 0;
      //           comparison = aDealValue - bDealValue;
      //           break;
      //         }
      //         case 'name': {
      //           const aName = aConv.name || '';
      //           const bName = bConv.name || '';
      //           comparison = aName.localeCompare(bName);
      //           break;
      //         }
      //       }

      //       // Apply sort direction
      //       const result = sortDirection === 'asc' ? comparison : -comparison;

      //       // If this field produces a non-zero result, use it
      //       // Otherwise, continue to the next sort field
      //       if (result !== 0) {
      //         return result;
      //       }
      //     }

      //     // All sort fields were equal
      //     return 0;
      //   });
      // }

      // Update conversationsSlice with conversation data
      // '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' });

      // Update conversationsSlice with ordered list (for keyboard navigation)
      // Transform conversationIds to ConversationSummary objects
      const conversationList: ConversationSummary[] = conversationIds.map((id) => {
        const conv = conversationsMap[id];
        return {
          id,
          name: conv.conversation.name || undefined,
        };
      });
      setCurrentConversationList(conversationList);
    } catch (error) {
      console.error('[useCRMConversations] Failed to update state:', error);
    }
  }, [
    rawConversations,
    filters,
    columns,
    aops,
    setConversations,
    setCurrentConversationList,
    conversationsQuery.data,
    selectedAopIds,
    isOverrideMode,
    viewConfigOverrides?.filterSortConfiguration,
  ]);

  // Auto-fetch more when filtered down to too few results
  useEffect(() => {
    const visibleCount = useCedarStore.getState().getCurrentConversationList().length;
    const hasNextPage = conversationsQuery.hasNextPage;

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

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

    if (!conversationsQuery.hasNextPage) {
      return;
    }

    await conversationsQuery.fetchNextPage();
  };

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