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