use-crm-configuration.ts18.9 KBView on GitHub

Introduced 1 production defect in 180 days, median 221 days to fix.

/**
 * useCRMConfiguration Hook
 *
 * Computes and validates the unified CRM configuration:
 * - Selected AOP → column options (including custom fields)
 * - Column configuration (widths, order, visibility, sorting, filtering)
 * - Enum options for select columns from AOP's conversationFieldDefinitions
 * - Validates that filter/sort state is valid for current AOP
 *
 * This hook handles AOP changes and ensures column state stays in sync.
 *
 * AOP data for imperative access (agent context) is now available directly
 * via the AOP slice (aopSlice.aopsById), populated by useAOPs() on fetch.
 */

import { COLUMNS_WITH_NULL_OPTION, isAopMergeableField } from '../field-enums';
import { COLUMN_PRESETS } from '../components/conversation-canvas/ColumnConfigurationItem';
import type { CRMColumn, CRMColumnMetadata, ColumnType } from '../store/crmSlice';
import { conversationColumnsConfig } from '../config/conversation-columns';
import { mergeEnumOptionsFromAops } from '../utils/merge-enum-options';
import { getVisibleFieldDefinitions } from '../utils/background-fields';
import { useAOPs } from '@/modules/aop/hooks/use-aops';
import { useMemo, useEffect, useRef } from 'react';
import { useCedarStore } from '@/modules/store';
import Fuse from 'fuse.js';

/**
 * Check if a column should have textarea width (300px default)
 */
function isTextareaColumn(columnId: string): boolean {
  return (
    columnId === 'primaryCompany' ||
    columnId === 'status' ||
    columnId === 'scheduledAction' ||
    columnId === 'nextSteps' ||
    columnId === 'history'
    // Custom fields (wm_*) are handled by type-specific width logic in initializeColumns
  );
}

/**
 * CRM Configuration computed from selected AOPs + user preferences
 */
export interface CRMConfiguration {
  // Selected AOPs (can include null to filter for "no AOP" conversations)
  selectedAopIds: (string | null)[] | null;
  selectedAopNames: (string | null)[];

  // Whether custom fields should be shown (only when exactly one AOP is selected)
  showCustomFields: boolean;

  // Visible columns only (filtered by visibility)
  columns: CRMColumn[];

  // All columns including hidden ones (for future column management UI)
  allColumns: CRMColumn[];

  // Column metadata map for quick lookup (includes all columns)
  columnMap: Map<string, CRMColumn>;

  // Validation state
  hasInvalidFilters: boolean;
  hasInvalidSorts: boolean;
}

/**
 * Optional overrides for canvas components that read display config from viewConfig
 * rather than from the global CRMSlice. When provided, the hook uses these values
 * instead of reading selectedAopIds / activePresetId from Zustand.
 */
export interface CRMConfigurationOverrides {
  /** AOP IDs from canvas viewConfig.selectedAopIds */
  selectedAopIds?: (string | null)[] | null;
  /** Preset ID from canvas viewConfig.presetId */
  activePresetId?: string | null;
}

/**
 * Hook to compute unified CRM configuration
 *
 * @param overrides - Optional values from canvas viewConfig that bypass the global CRMSlice
 * @returns CRM configuration object
 */
export function useCRMConfiguration(overrides?: CRMConfigurationOverrides): CRMConfiguration {
  // Always read from store (hooks must not be called conditionally)
  const selectedAopIdsFromStore = useCedarStore((state) => state.selectedAopIds);
  const setSelectedAopIds = useCedarStore((state) => state.setSelectedAopIds);

  // Use override if provided, otherwise fall back to store
  const selectedAopIds =
    overrides?.selectedAopIds !== undefined ? overrides.selectedAopIds : selectedAopIdsFromStore;

  // Whether we're running in canvas override mode (skip auto-select store writes)
  const isSelectedAopIdsOverridden = overrides?.selectedAopIds !== undefined;

  // Get column configs from store
  const columns = useCedarStore((state) => state.columns);
  const activePresetIdFromStore = useCedarStore((state) => state.activePresetId);
  const initializeColumns = useCedarStore((state) => state.initializeColumns);
  const setColumnVisibilities = useCedarStore((state) => state.setColumnVisibilities);

  // Use override preset if provided, otherwise fall back to store
  const activePresetId =
    overrides?.activePresetId !== undefined ? overrides.activePresetId : activePresetIdFromStore;

  // Fetch AOPs
  const { data: aopsData } = useAOPs();

  // Auto-select default AOP on first load, or clear invalid selections
  // - null = first load → auto-select "deals" AOP
  // - [] = user explicitly chose all AOPs → keep it
  // - [valid uuids] = valid selection → keep it
  // - [null] or [..., null] = include null is valid (for "no AOP" filter)
  // - [invalid uuids] = remove invalid ones
  useEffect(() => {
    // In canvas override mode, selectedAopIds comes from viewConfig — skip auto-select
    if (isSelectedAopIdsOverridden) return;
    if (!aopsData?.aops || aopsData.aops.length === 0) return;

    // Empty array is a valid explicit choice (show all) - don't change it
    if (selectedAopIdsFromStore !== null && selectedAopIdsFromStore.length === 0) return;

    // Check if current selectedAopIds are all valid
    if (selectedAopIdsFromStore !== null && selectedAopIdsFromStore.length > 0) {
      const validAopIds = new Set(aopsData.aops.map((aop) => aop.id));
      // null is always valid (represents "no AOP" filter option)
      const allValid = selectedAopIdsFromStore.every((id) => id === null || validAopIds.has(id));
      if (allValid) {
        // All selections are valid, nothing to do
        return;
      }
      // Some invalid IDs - filter them out (keep null and valid IDs)
      const validSelections = selectedAopIdsFromStore.filter(
        (id) => id === null || validAopIds.has(id),
      );
      if (validSelections.length > 0) {
        console.warn('Removed invalid AOP IDs from selection');
        setSelectedAopIds(validSelections);
        return;
      }
      // All IDs were invalid - fall through to auto-select
      console.warn('All selected AOP IDs were invalid. Auto-selecting default.');
    }

    // First load (null) or all invalid - auto-select "deals"
    // Use fuzzy matching to find the best "deals" AOP
    const fuse = new Fuse(aopsData.aops, {
      keys: ['name'],
      threshold: 0.4, // Lower = stricter matching
      includeScore: true,
    });

    const results = fuse.search('deals');
    const dealsAop = results.length > 0 ? results[0].item : null;

    // Use deals AOP if found, otherwise use first AOP
    const defaultAop = dealsAop || aopsData.aops[0];
    setSelectedAopIds([defaultAop.id]);
  }, [isSelectedAopIdsOverridden, selectedAopIdsFromStore, aopsData?.aops, setSelectedAopIds]);

  // Determine if custom fields should be shown (only when exactly one real AOP is selected)
  // Note: null in selectedAopIds represents "no AOP" filter, not a real AOP selection
  const showCustomFields = useMemo(() => {
    if (selectedAopIds === null) return false;
    const realAopIds = selectedAopIds.filter((id) => id !== null);
    return realAopIds.length === 1;
  }, [selectedAopIds]);

  // Find selected AOP for custom fields (only when exactly one real AOP is selected)
  const selectedAop = useMemo(() => {
    if (!showCustomFields || !selectedAopIds || !aopsData?.aops) return null;
    // Find the first non-null AOP ID
    const realAopId = selectedAopIds.find((id) => id !== null);
    if (!realAopId) return null;
    return aopsData.aops.find((aop) => aop.id === realAopId) || null;
  }, [showCustomFields, selectedAopIds, aopsData?.aops]);

  // Get ALL selected AOPs (for merging enum options across multiple AOPs)
  // This is used for status/priority when multiple AOPs are selected
  const selectedAops = useMemo(() => {
    if (!selectedAopIds || selectedAopIds.length === 0 || !aopsData?.aops) return [];
    const realAopIds = selectedAopIds.filter((id): id is string => id !== null);
    return aopsData.aops.filter((aop) => realAopIds.includes(aop.id));
  }, [selectedAopIds, aopsData?.aops]);

  // Get all selected AOP names for display (includes null for "no AOP" option)
  const selectedAopNames = useMemo((): (string | null)[] => {
    if (!selectedAopIds) return [];
    return selectedAopIds
      .map((id) => {
        if (id === null) return null; // Preserve null for "no AOP" option
        return aopsData?.aops?.find((aop) => aop.id === id)?.name ?? null;
      })
      .filter((name): name is string | null => name !== undefined);
  }, [selectedAopIds, aopsData?.aops]);

  // Generate custom fields columns from selected AOP (only when exactly one AOP is selected)
  const aopColumns = useMemo(() => {
    // Only show custom fields when exactly one AOP is selected
    if (!showCustomFields || !selectedAop?.customFieldDefinitions) return [];

    // Exclude background (sub-event taxonomy) fields — they are never user-facing columns.
    const visibleDefs = getVisibleFieldDefinitions(selectedAop.customFieldDefinitions);

    const parentFields: CRMColumnMetadata[] = [];

    const NON_VALUE_SORTABLE = new Set(['text', 'url', 'email', 'phone']);
    Object.entries(visibleDefs).forEach(([fieldId, field]) => {
      parentFields.push({
        id: `wm_${fieldId}`,
        name: field.label,
        type: field.type as CRMColumnMetadata['type'],
        description: field.description || '',
        enumOptions: field.options,
        valueSortable: !NON_VALUE_SORTABLE.has(field.type),
        hasSignal: field.signal?.enabled === true,
        filterable: true,
        filterDimensions: [
          'value' as const,
          ...(field.signal?.enabled ? (['signal'] as const) : []),
        ],
      });
    });

    // Sort by displayOrder
    parentFields.sort(
      (a, b) =>
        (visibleDefs[a.id.substring(3)]?.displayOrder ?? 0) -
        (visibleDefs[b.id.substring(3)]?.displayOrder ?? 0),
    );

    // Emit parent columns followed by the signal sub-column
    const result: CRMColumnMetadata[] = [];
    for (const meta of parentFields) {
      result.push(meta);
      if (meta.hasSignal) {
        result.push({
          id: `${meta.id}_signal`,
          name: 'Signal',
          type: 'score',
          description: `Signal for ${meta.name}`,
          parentColumnId: meta.id,
          signalDimension: 'signal',
        });
      }
    }

    return result;
  }, [showCustomFields, selectedAop]);

  // Initialize columns when AOP changes
  // Computes initialVisibility from activePresetId so that new columns are created
  // with the correct visibility from the start.
  useEffect(() => {
    const baseColumnIds = conversationColumnsConfig.map((col) => col.id);
    const aopColumnIds = aopColumns.map((col) => col.id);

    // Create a map for quick column type lookup
    const columnTypeMap = new Map<string, CRMColumnMetadata['type']>();
    conversationColumnsConfig.forEach((col) => {
      columnTypeMap.set(col.id, col.type);
    });
    aopColumns.forEach((col) => {
      columnTypeMap.set(col.id, col.type);
    });

    const getColumnType = (columnId: string) =>
      columnTypeMap.get(columnId) as ColumnType | undefined;

    let initialVisibility: Record<string, boolean> | undefined;
    if (activePresetId) {
      const preset = COLUMN_PRESETS.find((p) => p.id === activePresetId);
      if (preset) {
        const visibleSet = new Set(preset.columnIds);
        initialVisibility = {};

        // Set visibility for all columns based on preset
        [...baseColumnIds, ...aopColumnIds].forEach((columnId) => {
          // primaryCompany is always visible
          if (columnId === 'primaryCompany') {
            initialVisibility![columnId] = true;
          } else if (
            columnId.startsWith('wm_') || // Custom fields
            columnId === 'timeline' ||
            columnId === 'working-memory'
          ) {
            // These special columns are hidden by default
            initialVisibility![columnId] = false;
          } else {
            initialVisibility![columnId] = visibleSet.has(columnId);
          }
        });
      }
    }

    initializeColumns(
      baseColumnIds,
      aopColumnIds,
      isTextareaColumn,
      getColumnType,
      initialVisibility,
    );
  }, [aopColumns, initializeColumns, activePresetId]);

  // Effect B: Apply active preset visibility to ALL columns when activePresetId changes.
  // This handles tab switches where columns already exist but need their visibility updated.
  // initializeColumns only sets visibility for NEW columns; this covers existing ones.
  const prevActivePresetIdRef = useRef<string | null | undefined>(undefined);

  useEffect(() => {
    // In canvas override mode, the preset comes from viewConfig — skip global store writes
    if (overrides?.activePresetId !== undefined) return;
    const hasExistingColumns = Object.keys(useCedarStore.getState().columns).length > 0;
    if (!hasExistingColumns || !activePresetId) return;

    // Only run when activePresetId actually changed (not on every render)
    if (prevActivePresetIdRef.current === activePresetId) return;
    prevActivePresetIdRef.current = activePresetId;

    const preset = COLUMN_PRESETS.find((p) => p.id === activePresetId);
    if (!preset) return;

    const visibleSet = new Set(preset.columnIds);
    const visibilities: Record<string, boolean> = {};

    // Apply to all columns currently in the store
    Object.keys(columns).forEach((columnId) => {
      if (columnId === 'primaryCompany') {
        visibilities[columnId] = true;
      } else if (
        columnId.startsWith('wm_') ||
        columnId === 'timeline' ||
        columnId === 'working-memory'
      ) {
        visibilities[columnId] = false;
      } else {
        visibilities[columnId] = visibleSet.has(columnId);
      }
    });

    setColumnVisibilities(visibilities);
  }, [activePresetId, columns, setColumnVisibilities]);

  // Compute enriched columns (merge base metadata + column config + AOP enum options)
  const enrichedColumns = useMemo(() => {
    // Create map of all columns (base + AOP custom fields)
    const allColumnsMetadata = new Map<string, CRMColumnMetadata>();

    conversationColumnsConfig.forEach((col) => {
      allColumnsMetadata.set(col.id, col);
    });

    aopColumns.forEach((col) => {
      allColumnsMetadata.set(col.id, col);
    });

    // Get all column configs from Zustand (sorted by order, includes sort/filter state)
    const columnConfigs = Object.values(columns).sort((a, b) => a.order - b.order);

    // Ensure primaryCompany is always first
    const companyIndex = columnConfigs.findIndex((col) => col.id === 'primaryCompany');
    if (companyIndex > 0) {
      const [company] = columnConfigs.splice(companyIndex, 1);
      columnConfigs.unshift(company);
    }

    // Merge metadata + config + AOP enum options
    const enriched: CRMColumn[] = columnConfigs
      .map((colConfig): CRMColumn | null => {
        const baseCol = allColumnsMetadata.get(colConfig.id);
        if (!baseCol) return null;

        // Get enum options from AOP's conversationFieldDefinitions
        let enumOptions = baseCol.enumOptions;

        // Type column: populate with all AOP names
        if (colConfig.id === 'type' && aopsData?.aops) {
          enumOptions = aopsData.aops.map((aop, index) => ({
            value: aop.name,
            label: aop.name,
            color: '', // No specific color for types
            enumOrder: index,
          }));
        } else if (colConfig.id === 'statusBadge') {
          // statusBadge uses the same enum options as status
          const mergedOptions = mergeEnumOptionsFromAops(selectedAops, 'status');
          if (mergedOptions.length > 0) {
            enumOptions = mergedOptions;
          }
        } else if (isAopMergeableField(colConfig.id)) {
          // Merge options from all selected AOPs for status/priority (dedupe by value)
          const mergedOptions = mergeEnumOptionsFromAops(selectedAops, colConfig.id);
          if (mergedOptions.length > 0) {
            enumOptions = mergedOptions;
          }
        }

        // Add null option for empty values to columns with enum options
        // Note: 'crmSynced' doesn't support null since it's a computed boolean field
        if (COLUMNS_WITH_NULL_OPTION.has(colConfig.id) && enumOptions && enumOptions.length > 0) {
          // Check if null option already exists
          const hasEmptyOption = enumOptions.some((opt) => opt.value === null);
          if (!hasEmptyOption) {
            enumOptions = [
              {
                value: null,
                label: 'No type',
                color: 'gray',
                enumOrder: -1,
              },
              ...enumOptions,
            ];
          }
        }

        // Build enriched column with all state
        return {
          ...baseCol, // type, name, description, isFixed
          ...colConfig, // width, order, visible, minWidth, sort, filter (user state from ColumnConfigState)
          enumOptions, // Updated from AOP if available
        } as CRMColumn;
      })
      .filter((col): col is CRMColumn => col !== null);

    return enriched;
  }, [aopColumns, columns, selectedAops, aopsData]);

  // Validate filters: check if any column has filters for values that don't exist in enum options
  const hasInvalidFilters = useMemo(() => {
    return enrichedColumns.some((col) => {
      if (!col.filter || !col.enumOptions || col.enumOptions.length === 0) {
        return false;
      }

      const validValues = new Set(col.enumOptions.map((opt) => opt.value));

      // Check selected values
      if (col.filter.selected) {
        const hasInvalidSelected = col.filter.selected.some((val) => !validValues.has(val));
        if (hasInvalidSelected) return true;
      }

      // Check excluded values
      if (col.filter.excluded) {
        const hasInvalidExcluded = col.filter.excluded.some((val) => !validValues.has(val));
        if (hasInvalidExcluded) return true;
      }

      return false;
    });
  }, [enrichedColumns]);

  // Validate sorts: check if any sorted column has customOrder with invalid values
  const hasInvalidSorts = useMemo(() => {
    return enrichedColumns.some((col) => {
      if (!col.sort?.active || !col.sort.order || !col.enumOptions) {
        return false;
      }

      const validValues = new Set(col.enumOptions.map((opt) => opt.value));
      return col.sort.order.some((val) => !validValues.has(val));
    });
  }, [enrichedColumns]);

  // Filter to visible columns only
  // Use the column visibility settings from the store
  // (Agent sets visibility via findCRMConversationsTool's visibleColumns parameter)
  const visibleColumns = useMemo(() => {
    return enrichedColumns.filter((col) => col.visible);
  }, [enrichedColumns]);

  // Create column map for quick lookup (includes all columns for future use)
  const columnMap = useMemo(() => {
    const map = new Map<string, CRMColumn>();
    enrichedColumns.forEach((col) => map.set(col.id, col));
    return map;
  }, [enrichedColumns]);

  return {
    selectedAopIds,
    selectedAopNames,
    showCustomFields,
    columns: visibleColumns, // Only visible columns by default
    allColumns: enrichedColumns, // All columns including hidden ones
    columnMap,
    hasInvalidFilters,
    hasInvalidSorts,
  };
}