use-canvas-list-variables.ts15.4 KBView on GitHub
/**
 * useCanvasListVariables — the one place a canvas's filter set is derived.
 *
 * Pulled out of `useCanvasConversations` so the rows and their roll-up numbers are computed
 * from the SAME object. The metrics bar sits directly above the table; if the two derived
 * their filters independently they would eventually disagree, and a summary that contradicts
 * the rows under it is worse than no summary at all. See
 * apps/mail/docs/pipeline-tabs-and-canvas-aggregates.md §3.2.
 *
 * Returns `listVariables` (the exact tRPC input, minus cursor) plus the few intermediates
 * `useCanvasConversations` still needs for its query key and post-fetch sync.
 */

import {
  computeFiltersFromConfig,
  computeBackendSortFromConfig,
  applyDynamicDates,
} from '../utils/compute-canvas-filters';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useCanvasConfiguration } from '@/modules/crm/hooks/use-canvas-configuration';
import { useMemo, useEffect, useState } from 'react';
import { useCedarStore } from '@/modules/store';

export function useCanvasListVariables(
  canvasId: string | null | undefined,
  pinnedConversationIds?: string[],
  options?: { limit?: number },
) {
  const pageLimit = options?.limit ?? 50;

  // ── Single source of truth: useCanvasConfiguration ──────────────────────
  const canvasConfig = useCanvasConfiguration(canvasId);
  const { selectedAopIds, customFieldDefinitions } = canvasConfig;

  // Read viewConfig for filterSortConfiguration + searchQuery
  const viewConfig = useCedarStore((state) => {
    if (!canvasId) return null;
    const c = state.canvasesById[canvasId];
    return c?.viewConfig as ConversationViewConfig | null | undefined;
  });

  // Read ownerUserIds filter from viewConfig.
  // Semantics (passed directly to backend):
  //   undefined/null/[] → current user's conversations only (default)
  //   'all_org'         → org-wide search
  //   [id, ...]         → filter to specific owners
  const ownerUserIds = viewConfig?.ownerUserIds;

  // AOP list for sort (type column → maps AOP names to IDs)
  const aopsById = useCedarStore((state) => state.aopsById);
  const aops = useMemo(
    () => Object.values(aopsById).map((a) => ({ id: a.id, name: a.name })),
    [aopsById],
  );

  // Apply dynamic dates (e.g. 'deal-actions' tab resolves "before" to end of today)
  const filterSortConfig = useMemo(
    () =>
      viewConfig?.filterSortConfiguration
        ? applyDynamicDates(viewConfig.filterSortConfiguration, canvasId ?? '')
        : {},
    [viewConfig?.filterSortConfiguration, canvasId],
  );
  const searchQuery = viewConfig?.searchQuery;

  // ── 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(
    () => Object.values(filterSortConfig).some((col) => col.filter?.dateRelative),
    [filterSortConfig],
  );
  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 ─────────────────────────────────────────────────────
  const { filters, customFieldFilters: backendCustomFieldFilters } = useMemo(
    () => computeFiltersFromConfig(filterSortConfig, searchQuery, customFieldDefinitions),
    // dateEpoch forces re-resolution of relative date offsets when the day rolls over
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [filterSortConfig, searchQuery, customFieldDefinitions, dateEpoch],
  );

  // ── Compute AOP IDs for backend ─────────────────────────────────────────
  const backendAopIds = useMemo(() => {
    if (!selectedAopIds || selectedAopIds.length === 0) return undefined;
    return selectedAopIds;
  }, [selectedAopIds]);

  // ── Compute individual filter fields (mirrors useCRMConversations) ──────
  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]);

  const backendNextStepDate = useMemo(() => filters.nextStepDate?.date, [filters.nextStepDate]);
  const backendNextStepDateOperator = useMemo(
    () => filters.nextStepDate?.operator,
    [filters.nextStepDate?.operator],
  );
  const backendNextStepDateTo = useMemo(
    () => filters.nextStepDate?.dateTo,
    [filters.nextStepDate?.dateTo],
  );

  const backendLastContactDate = useMemo(() => filters.lastContact?.date, [filters.lastContact]);
  const backendLastContactDateOperator = useMemo(
    () => filters.lastContact?.operator,
    [filters.lastContact?.operator],
  );
  const backendLastContactDateTo = useMemo(
    () => 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(
    () => filters.lastMeeting?.operator,
    [filters.lastMeeting?.operator],
  );

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

  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(
    () => filters.dealValue?.operator,
    [filters.dealValue?.operator],
  );

  const backendCrmSynced = useMemo(() => filters.crmSynced, [filters.crmSynced]);
  const backendHasFutureCalendar = useMemo(() => 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(() => 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],
  );
  const backendCurrentActionHasTasks = useMemo(
    () => filters.currentActionHasTasks,
    [filters.currentActionHasTasks],
  );
  const backendCurrentActionDueBefore = useMemo(
    () => filters.currentActionDueBefore,
    [filters.currentActionDueBefore],
  );
  const backendCurrentActionTaskTypes = useMemo(
    () => filters.currentActionTaskTypes,
    [filters.currentActionTaskTypes],
  );

  const backendEventFilter = useMemo(() => {
    const hasInclude = filters.eventTypes && filters.eventTypes.length > 0;
    const hasExclude = filters.excludedEventTypes && filters.excludedEventTypes.length > 0;
    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]);

  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]);

  // ── Compute backend sort ────────────────────────────────────────────────
  const backendSortBy = useMemo(
    () => computeBackendSortFromConfig(filterSortConfig, aops, customFieldDefinitions),
    [filterSortConfig, aops, customFieldDefinitions],
  );

  // ── Build list variables ────────────────────────────────────────────────
  const isPinnedToList = Boolean(pinnedConversationIds && pinnedConversationIds.length > 0);

  const listVariables = useMemo(() => {
    if (isPinnedToList) {
      return {
        conversationIds: pinnedConversationIds,
        limit: pageLimit,
        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,
        ownerUserIds: 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 && backendCustomFieldFilters.length > 0
          ? backendCustomFieldFilters
          : undefined,
      ownerUserIds,
      limit: pageLimit,
      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,
    backendCurrentActionTaskTypes,
    backendEventFilter,
    backendLatestEventFilter,
    backendSortBy,
    backendCustomFieldFilters,
    ownerUserIds,
    pageLimit,
  ]);
  return {
    listVariables,
    filters,
    filterSortConfig,
    dateEpoch,
    hasRelativeFilters,
    customFieldDefinitions,
    selectedAopIds,
  };
}