use-conversations-sidebar-conversations.ts11.4 KBView on GitHub

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

import { useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTRPCClient } from '@/modules/trpc/context';
import { useCedarStore } from '@/modules/store';
import { applyPendingConversationFields } from '@/modules/crm/lib/pending-conversation-field-writes';
import {
  computeFiltersFromConfig,
  computeBackendSortFromConfig,
} from '@/modules/crm/utils/compute-canvas-filters';
import { useSidebarAllColumns } from './use-sidebar-all-columns';

interface UseConversationsSidebarResult {
  conversations: SidebarConversation[];
  isLoading: boolean;
  isFetchingNextPage: boolean;
  hasMore: boolean;
  loadMore: () => void;
  refetch: () => void;
}

/**
 * Pulls the conversation list backing the /conversations sidebar.
 *
 * Phase 3: drives `listConversations` input through the shared
 * `computeFiltersFromConfig` / `computeBackendSortFromConfig` utilities so
 * every filter operator and sort dimension supported by the CRM canvas works
 * here too. Search stays client-side via `filterConversationsByQuery` in
 * `ConversationsSidebarBody`.
 */
type ListConversationsInput = Parameters<
  ReturnType<typeof useTRPCClient>['crm']['listConversations']['query']
>[0];

/**
 * Read off the router rather than described here. This list is the LIST projection of a
 * conversation, which is a different shape from the detail one, and the two drift — writing
 * the shape out by hand (or casting through `any`) is what turns a server-side field rename
 * into malformed sidebar rows at runtime instead of a red squiggle at the keyboard.
 */
type ListConversationsPage = Awaited<
  ReturnType<ReturnType<typeof useTRPCClient>['crm']['listConversations']['query']>
>;
type ListConversationsRow = ListConversationsPage['conversations'][number];

/**
 * One row as the SIDEBAR holds it — inferred from `toSidebarConversation` rather than written
 * out, so it is by construction whatever the list projection actually produces.
 *
 * It is deliberately NOT `HydratedConversation`. That type describes the DETAIL projection,
 * and the list ships strictly less: a custom field arrives without its working-memory
 * provenance, `latestEvent` is an overview event, and the conversation itself has no
 * `lastReviewedAt` / `conversationScope` / `overviewItems`. Claiming otherwise took four
 * casts, and their effect was that a reader reaching for one of those fields on a sidebar row
 * compiled clean and read `undefined` at runtime.
 */
export type SidebarConversation = ReturnType<typeof toSidebarConversation>;

function toSidebarConversation(row: ListConversationsRow) {
  const events = row.events ?? [];
  return {
    // The pending-write mask: this list is the one conversation surface that does NOT go
    // through `setConversations`, so without it the row keeps showing the AOP the user just
    // changed away from. It is generic over its argument, so the row's own type survives.
    // A no-op once the refetch this edit triggered has landed.
    conversation: applyPendingConversationFields(row.id, { ...row, events }),
    people: [],
    company: row.primaryCompany ?? row.conversationCompanies?.[0]?.company ?? null,
    customFields: row.customFields ?? [],
    userTasks: row.userTasks ?? [],
    // `.at(0)`, not `[0]`: a deal with no events has no latest event, and the type has to
    // admit it. `noUncheckedIndexedAccess` is off, so `events[0]` reads as always-present and
    // TypeScript discards the `?? null` as dead — leaving `latestEvent` typed as a thing that
    // is always there while the value is null on every eventless row.
    // `Array.prototype.at` is typed `T | undefined` unconditionally, so the union survives.
    // `filterConversationsByQuery` already guards this with `?.`, which is the tell.
    latestEvent: events.at(0) ?? null,
    scheduledCalendarEvents: row.scheduledCalendarEvents ?? [],
    ownerUser: row.ownerUser ?? null,
    // Computed by `listConversationsSingleQuery` but dropped when `crm.listConversations`
    // flattens the hydrated conversation, so nothing over the wire carries them.
    unreadEmailCount: row.unreadEmailCount ?? 0,
    openTaskCount: row.openTaskCount ?? 0,
  };
}

export function useConversationsSidebarConversations(): UseConversationsSidebarResult {
  const trpcClient = useTRPCClient();
  const columns = useCedarStore((s) => s.conversationsSidebar.columns);
  const aopsById = useCedarStore((s) => s.aopsById);
  const { customFieldDefinitions } = useSidebarAllColumns();

  const aops = useMemo(
    () => Object.values(aopsById).map((a) => ({ id: a.id, name: a.name })),
    [aopsById],
  );

  const { filters, customFieldFilters } = useMemo(
    () => computeFiltersFromConfig(columns, /* searchQuery */ '', customFieldDefinitions),
    [columns, customFieldDefinitions],
  );

  const sortBy = useMemo(
    () => computeBackendSortFromConfig(columns, aops, customFieldDefinitions),
    [columns, aops, customFieldDefinitions],
  );

  const listVariables = useMemo<Omit<ListConversationsInput, 'cursor'>>(
    () => ({
      limit: 200,
      // Status / priority — include + exclude
      status:
        filters.status?.length || filters.excludedStatus?.length
          ? {
              include: filters.status?.length ? filters.status : undefined,
              exclude: filters.excludedStatus?.length ? filters.excludedStatus : undefined,
            }
          : undefined,
      priority:
        filters.priority?.length || filters.excludedPriority?.length
          ? {
              include: filters.priority?.length ? filters.priority : undefined,
              exclude: filters.excludedPriority?.length ? filters.excludedPriority : undefined,
            }
          : undefined,
      // Date filters
      lastContactDate:
        filters.lastContact?.operator === 'empty' ? undefined : filters.lastContact?.date,
      lastContactDateOperator: filters.lastContact?.operator,
      lastContactDateTo: filters.lastContact?.dateTo,
      nextStepDate:
        filters.nextStepDate?.operator === 'empty' ? undefined : filters.nextStepDate?.date,
      nextStepDateOperator: filters.nextStepDate?.operator,
      nextStepDateTo: filters.nextStepDate?.dateTo,
      lastMeetingDate:
        filters.lastMeeting?.operator === 'empty' ? undefined : filters.lastMeeting?.date,
      lastMeetingDateOperator: filters.lastMeeting?.operator,
      lastMeetingDateTo: filters.lastMeeting?.dateTo,
      lastEventByTypeDate:
        filters.lastEventByType?.operator === 'empty' ? undefined : filters.lastEventByType?.date,
      lastEventByTypeDateOperator: filters.lastEventByType?.operator,
      lastEventByTypeDateTo: filters.lastEventByType?.dateTo,
      lastEventByTypeTypes: filters.lastEventByType?.types,
      // Deal value
      dealValue:
        filters.dealValue?.operator === 'empty' || !filters.dealValue?.value
          ? undefined
          : Number.isNaN(Number(filters.dealValue.value))
            ? undefined
            : Number(filters.dealValue.value),
      dealValueOperator: filters.dealValue?.operator,
      // Tasks / actions
      crmSynced: filters.crmSynced,
      hasFutureCalendar: filters.hasFutureCalendar,
      hasTodoTasks: filters.hasTodoTasks,
      taskTypes: filters.taskTypes,
      taskDueDate:
        filters.taskDueDate?.operator === 'empty' ? undefined : filters.taskDueDate?.date,
      taskDueDateOperator: filters.taskDueDate?.operator,
      taskDueDateTo: filters.taskDueDate?.dateTo,
      currentActionHasTasks: filters.currentActionHasTasks,
      currentActionDueBefore: filters.currentActionDueBefore,
      currentActionTaskTypes: filters.currentActionTaskTypes,
      // Event filters
      eventFilter: buildEventFilter(filters),
      latestEventFilter: buildLatestEventFilter(filters),
      // AOP filter (column id = 'aopId')
      aopIds: columns.aopId?.filter?.selected?.filter(
        (v): v is string => typeof v === 'string',
      ),
      // Sort
      sortBy,
      customFieldFilters: customFieldFilters.length > 0 ? customFieldFilters : undefined,
    }),
    [filters, sortBy, customFieldFilters, columns.aopId],
  );

  const query = useInfiniteQuery({
    // Nest under the tRPC-canonical prefix so optimistic invalidations on
    // trpc.crm.listConversations.queryKey() also reach the sidebar list.
    queryKey: [['crm', 'listConversations'], 'conversationsSidebar', listVariables],
    queryFn: async ({ pageParam }) => {
      return await trpcClient.crm.listConversations.query({
        ...listVariables,
        cursor: typeof pageParam === 'number' ? pageParam : undefined,
      });
    },
    initialPageParam: undefined as number | undefined,
    getNextPageParam: (lastPage: ListConversationsPage) => lastPage.nextCursor ?? undefined,
    staleTime: 30 * 1000,
  });

  const conversations = useMemo<SidebarConversation[]>(
    () => (query.data?.pages ?? []).flatMap((page) => page.conversations.map(toSidebarConversation)),
    [query.data?.pages],
  );

  return {
    conversations,
    isLoading: query.isLoading,
    isFetchingNextPage: query.isFetchingNextPage,
    hasMore: query.hasNextPage ?? false,
    loadMore: () => {
      if (query.hasNextPage && !query.isFetchingNextPage) query.fetchNextPage();
    },
    refetch: () => {
      query.refetch();
    },
  };
}

// ──────────────────────────────────────────────────────────────────────────────
// Event-filter shape builders (mirrors use-canvas-conversations.ts)
// ──────────────────────────────────────────────────────────────────────────────

type CRMFiltersLike = ReturnType<typeof computeFiltersFromConfig>['filters'];

function buildEventFilter(filters: CRMFiltersLike) {
  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,
  };
}

function buildLatestEventFilter(filters: CRMFiltersLike) {
  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,
  };
}