aop-columns.ts3.7 KBView on GitHub
/**
 * aop-columns.ts
 *
 * The AOP-derived half of a CRM column set, shared by every surface that
 * offers filtering over conversations: the canvas (`useCanvasConfiguration`),
 * the conversations sidebar (`useSidebarAllColumns`), and the inbox editor
 * (`useAopFilterColumns`).
 *
 * Each surface layers its own concerns on top — canvas adds width/order/
 * visibility, the sidebar synthesizes them — but the mapping from a set of
 * AOPs to `wm_<fieldId>` columns and their merged field definitions is the
 * same everywhere, and drifting copies of it are how a custom field ends up
 * filterable on one surface and invisible on another.
 */

import { getVisibleFieldDefinitions } from './background-fields';
import type { CRMColumnMetadata } from '../store/crmSlice';
import type { CustomFieldDefinitions } from '../types';

/** Field types with no meaningful value ordering (free text). */
const NON_VALUE_SORTABLE = new Set(['text', 'url', 'email', 'phone']);

/** The subset of an AOP these builders read. */
export type AopWithCustomFields = {
  customFieldDefinitions?: CustomFieldDefinitions | null;
};

/**
 * Union of every selected AOP's visible custom field definitions, first
 * definition wins on id collisions. Consumed by `computeFiltersFromConfig` /
 * `computeBackendSortFromConfig` to resolve the type of a `wm_*` column.
 */
export function mergeCustomFieldDefinitions(
  aops: AopWithCustomFields[],
): CustomFieldDefinitions | undefined {
  if (aops.length === 0) return undefined;
  const merged: CustomFieldDefinitions = {};
  for (const aop of aops) {
    if (!aop.customFieldDefinitions) continue;
    // Skip background (sub-event taxonomy) fields — never surfaced as columns/filters.
    for (const [fieldId, field] of Object.entries(
      getVisibleFieldDefinitions(aop.customFieldDefinitions),
    )) {
      if (!(fieldId in merged)) merged[fieldId] = field;
    }
  }
  return Object.keys(merged).length > 0 ? merged : undefined;
}

/**
 * `wm_<fieldId>` column metadata for every selected AOP's custom fields,
 * deduped by column id and ordered by the field's `displayOrder`. A field with
 * signal scoring emits a trailing `_signal` sub-column.
 */
export function buildAopCustomFieldColumns(aops: AopWithCustomFields[]): CRMColumnMetadata[] {
  if (aops.length === 0) return [];

  const fieldMap = new Map<string, { meta: CRMColumnMetadata; displayOrder: number }>();

  for (const aop of aops) {
    if (!aop.customFieldDefinitions) continue;
    Object.entries(getVisibleFieldDefinitions(aop.customFieldDefinitions)).forEach(
      ([fieldId, field]) => {
        const colId = `wm_${fieldId}`;
        if (fieldMap.has(colId)) return;
        fieldMap.set(colId, {
          meta: {
            id: colId,
            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) : []),
            ],
          },
          displayOrder: field.displayOrder ?? 0,
        });
      },
    );
  }

  const sorted = Array.from(fieldMap.values()).sort((a, b) => a.displayOrder - b.displayOrder);

  const result: CRMColumnMetadata[] = [];
  for (const { meta } of sorted) {
    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;
}