use-optimistic-conversation-actions.ts19.7 KBView on GitHub
/**
 * Optimistic Conversation Actions Hook
 *
 * Provides optimistic update functions for conversation operations:
 * 1. Immediately update conversationsSlice (optimistic UI update)
 * 2. Call server mutation
 * 3. Invalidate queries to trigger CRMDataSync refetch
 * 4. On error, refetch will restore correct state
 *
 * Pattern inspired by use-optimistic-actions.ts but for conversation operations.
 */

import { triggerExecuteFromClientSend } from '@/modules/conversations/utils/triggerExecuteFromClientSend';
import { describeConversationUpdateError } from '../lib/conversation-update-error';
import {
  clearConversationFieldsPending,
  markConversationFieldsPending,
} from '../lib/pending-conversation-field-writes';
import type { HydratedConversation, WorkingMemoryEntry } from '../types';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { useCallback } from 'react';
import posthog from 'posthog-js';
import { toast } from 'sonner';

export function useOptimisticConversationActions() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  // Get conversationsSlice mutation functions
  const setConversations = useCedarStore((state) => state.setConversations);

  // Get server mutation functions
  const { mutateAsync: updateConversationServer } = useMutation(
    trpc.crm.updateConversation.mutationOptions(),
  );
  const { mutateAsync: deleteConversationServer } = useMutation(
    trpc.crm.deleteConversation.mutationOptions(),
  );
  const { mutateAsync: upsertWorkingMemoryServer } = useMutation(
    trpc.crm.upsertWorkingMemory.mutationOptions(),
  );

  /**
   * Optimistically update a conversation field
   */
  const optimisticUpdateConversation = useCallback(
    async (
      conversationId: string,
      updates: {
        name?: string | null;
        aopId?: string | null;
        status?: string | null;
        priority?: string | null;
        nextSteps?: string | null;
        nextStepDate?: Date | null;
        statusOverview?: string | null;
        notes?: string | null;
        dealValue?: number | null;
        important?: boolean;
      },
    ) => {
      if (!conversationId) return;

      // Get current conversation from conversationsSlice
      const currentConversation = useCedarStore.getState().conversations[conversationId];
      if (!currentConversation) {
        console.warn('[Conversations] Cannot update non-existent conversation:', conversationId);
        return;
      }

      // Capture old values before updating (for agent execution)
      const oldNextSteps = currentConversation.data.conversation.nextSteps;
      const oldNextStepDate = currentConversation.data.conversation.nextStepDate;

      // Hold these values above any server payload until the write has settled. The
      // conversation the UI renders comes from a Zustand mirror with many writers, and a
      // `listConversations` page fetched before this click still carries the old row — so
      // the optimistic value cannot live only in the write below, it has to be re-applied
      // on every ingest. See lib/pending-conversation-field-writes.ts.
      markConversationFieldsPending(conversationId, updates);

      const conversationQueryKey=[redacted] id: conversationId });

      // 1. Optimistic update: Update conversationsSlice immediately. Before any await —
      // this is the paint the user is waiting on, and the mask above already protects it
      // from whatever lands while the rest of this runs.
      const optimisticConversation: HydratedConversation = {
        ...currentConversation.data,
        conversation: {
          ...currentConversation.data.conversation,
          ...updates,
          // `crm_conversations.name` is NOT NULL — a null here means "leave it alone".
          name: updates.name ?? currentConversation.data.conversation.name,
          updatedAt: new Date(), // Update timestamp optimistically
        },
      };

      setConversations({ [conversationId]: optimisticConversation });

      // A `getConversation` fetch already in flight resolves with pre-edit data and would
      // overwrite the cache patch below — which `ConversationView` and `ThreadContextCards`
      // read straight out of, without passing through the mask. Cancel it; the invalidation
      // after the mutation re-fetches with the committed row. Deliberately NOT applied to
      // the `listConversations` prefix: cancelling that mid-pagination drops a page the
      // infinite scroll has already committed to.
      await queryClient.cancelQueries({ queryKey=[redacted] });

      // Also update React Query cache to prevent ConversationDataSync from overwriting with stale data
      queryClient.setQueryData(conversationQueryKey, optimisticConversation);

      // 2. Call server
      try {
        const result = await updateConversationServer({
          id: conversationId,
          ...updates,
        });

        posthog.capture('crm_conversation_updated');

        // Reassigning the AOP changes every AOP-derived surface (type badge, status/
        // priority enum options, custom field definitions, and the Strategic Overview
        // layout). The optimistic snapshot only carried the new aopId, so the cached
        // conversation + field-definition queries still describe the OLD AOP — that
        // mismatch is what makes the view flicker/render stale right after assignment.
        // Invalidate them so the server's AOP-consistent data loads cleanly.
        if ('aopId' in updates) {
          // The AOP refresh that follows is detached from this response (see the
          // 60s-timeout fix in apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md),
          // so the fields keep settling for ~30s afterwards via the invalidation below.
          // The runId handed to the store is what makes that wait legible.
          //
          // Hand the background refresh's runId to the store so the conversation shows
          // "Agent updating fields…" until that execution finishes (see
          // useAopRefreshWatcher). Absent when the server couldn't open the execution —
          // the refresh still runs, we just can't show progress for it.
          if (result.aopRefresh?.runId) {
            useCedarStore
              .getState()
              .setAopRefreshRun(conversationId, result.aopRefresh.runId);
          }

          await Promise.all([
            queryClient.invalidateQueries({ queryKey=[redacted] }),
            queryClient.invalidateQueries({
              queryKey=[redacted],
            }),
          ]);
        }

        // Fields the LIST is filtered or grouped on, decided server-side: `status` /
        // `priority` go into listConversations as `excludedStatus` / `excludedPriority`,
        // and `aopId` goes in as `aopIds` — and the conversation inbox additionally
        // GROUPS its rows by `aopId` read out of the store. Without re-running the query
        // nothing re-decides membership: marking a deal Closed Lost under "Exclude Closed
        // Lost" left it sitting in the pipeline, and re-assigning an AOP left every cached
        // page still holding the old one, ready to write it back over the change the next
        // time one of the four list hooks pushed into the store.
        if ('status' in updates || 'priority' in updates || 'aopId' in updates) {
          await queryClient.invalidateQueries({
            queryKey=[redacted],
          });
        }

        // Trigger agent execution after successful server update for nextSteps or nextStepDate changes
        const hasNextStepsKey=[redacted] in updates;
        const hasNextStepDateKey=[redacted] in updates;
        const nextStepsChanged = hasNextStepsKey && updates.nextSteps !== oldNextSteps;
        const nextStepDateChanged =
          hasNextStepDateKey && String(updates.nextStepDate) !== String(oldNextStepDate);

        if (nextStepsChanged || nextStepDateChanged) {
          // Build descriptive event summary with before/after values
          const oldConv = currentConversation.data.conversation;
          let eventSummary = 'User edited conversation fields:\n';

          if (nextStepsChanged) {
            const oldValue = oldConv.nextSteps || '(empty)';
            const newValue = updates.nextSteps || '(empty)';
            eventSummary += `- Next Steps changed from: "${oldValue}" to: "${newValue}"\n`;
          }

          if (nextStepDateChanged) {
            const oldDate = oldConv.nextStepDate
              ? oldConv.nextStepDate instanceof Date
                ? oldConv.nextStepDate.toISOString()
                : String(oldConv.nextStepDate)
              : '(no date)';
            const newDate = updates.nextStepDate
              ? updates.nextStepDate instanceof Date
                ? updates.nextStepDate.toISOString()
                : String(updates.nextStepDate)
              : '(no date)';
            eventSummary += `- Next Step Date changed from: ${oldDate} to: ${newDate}\n`;
          }

          triggerExecuteFromClientSend({
            conversationId,
            triggerSource: 'conversation_update',
            eventSummary,
          }).catch((err) => {
            console.warn('[Conversations] Failed to trigger next steps update:', err);
          });
        }
      } catch (error) {
        // Release the mask FIRST. It exists to keep server truth from winning while the
        // write settles; the write failed, so server truth is the truth again and the
        // refetches below have to be allowed through — otherwise the row the user was
        // told did not save would keep showing the value that did not save.
        clearConversationFieldsPending(conversationId, Object.keys(updates));

        // On error, invalidate to trigger refetch (will restore correct state)
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        // Also invalidate single conversation query
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        const errorMessage = describeConversationUpdateError(error, Object.keys(updates));
        toast.error(errorMessage);
        console.error('[Conversations] Error updating conversation:', {
          conversationId,
          fields: Object.keys(updates),
          error,
        });
        posthog.capture('crm_conversation_update_failed', {
          fields: Object.keys(updates),
          message: errorMessage,
        });
      }
    },
    [setConversations, updateConversationServer, queryClient, trpc],
  );

  /**
   * Optimistically update conversation status
   */
  const optimisticUpdateStatus = useCallback(
    async (conversationId: string, status: string) => {
      return optimisticUpdateConversation(conversationId, { status });
    },
    [optimisticUpdateConversation],
  );

  /**
   * Optimistically update conversation priority
   */
  const optimisticUpdatePriority = useCallback(
    async (conversationId: string, priority: string) => {
      return optimisticUpdateConversation(conversationId, { priority });
    },
    [optimisticUpdateConversation],
  );

  /**
   * Optimistically update conversation next steps
   */
  const optimisticUpdateNextSteps = useCallback(
    async (conversationId: string, nextSteps: string) => {
      return optimisticUpdateConversation(conversationId, { nextSteps });
    },
    [optimisticUpdateConversation],
  );

  /**
   * Optimistically update conversation next step date
   */
  const optimisticUpdateNextStepDate = useCallback(
    async (conversationId: string, nextStepDate: Date | null) => {
      return optimisticUpdateConversation(conversationId, { nextStepDate });
    },
    [optimisticUpdateConversation],
  );

  /**
   * Optimistically update conversation notes
   */
  const optimisticUpdateNotes = useCallback(
    async (conversationId: string, notes: string) => {
      return optimisticUpdateConversation(conversationId, { notes });
    },
    [optimisticUpdateConversation],
  );

  /**
   * Optimistically update a custom field (working memory entry)
   */
  const optimisticUpdateCustomField = useCallback(
    async (conversationId: string, fieldName: string, fieldValue: string) => {
      if (!conversationId) return;

      // Get current conversation from conversationsSlice
      const currentConversation = useCedarStore.getState().conversations[conversationId];
      if (!currentConversation) {
        console.warn(
          '[Conversations] Cannot update custom field for non-existent conversation:',
          conversationId,
        );
        return;
      }

      // 1. Optimistic update: Update conversationsSlice immediately
      const existingCustomFields = currentConversation.data.customFields || [];
      const existingFieldIndex = existingCustomFields.findIndex((f) => f.name === fieldName);

      let optimisticCustomFields: WorkingMemoryEntry[];
      if (existingFieldIndex >= 0) {
        // Update existing field
        optimisticCustomFields = existingCustomFields.map((field, idx) =>
          idx === existingFieldIndex
            ? {
                ...field,
                value: fieldValue,
                lastEdited: new Date(),
                updatedAt: new Date(),
              }
            : field,
        );
      } else {
        // Add new field (create minimal entry - server will return full entry)
        const newField: WorkingMemoryEntry = {
          id: `temp-${Date.now()}`, // Temporary ID, server will assign real one
          conversationId,
          name: fieldName,
          value: fieldValue,
          agentExecutionId: null,
          editedBy: 'user',
          lastEdited: new Date(),
          createdAt: new Date(),
          updatedAt: new Date(),
          signal: null,
          signalReasoning: null,
        };
        optimisticCustomFields = [...existingCustomFields, newField];
      }

      const optimisticConversation: HydratedConversation = {
        ...currentConversation.data,
        customFields: optimisticCustomFields,
        conversation: {
          ...currentConversation.data.conversation,
        },
      };

      setConversations({ [conversationId]: optimisticConversation });

      // Also update React Query cache to prevent ConversationDataSync from overwriting with stale data
      const conversationQueryKey=[redacted] id: conversationId });
      queryClient.setQueryData(conversationQueryKey, optimisticConversation);

      // 2. Call server
      try {
        await upsertWorkingMemoryServer({
          conversationId,
          name: fieldName,
          value: fieldValue,
        });

        posthog.capture('crm_custom_field_updated');
      } catch (error) {
        // On error, invalidate to trigger refetch (will restore correct state)
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        // Also invalidate single conversation query
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        toast.error('Failed to update custom field');
        console.error('[Conversations] Error updating custom field:', error);
      }
    },
    [setConversations, upsertWorkingMemoryServer, queryClient, trpc],
  );

  /**
   * Optimistically delete a conversation
   * Note: We don't remove from conversationsSlice immediately since it doesn't have a remove method.
   * Instead, we rely on the refetch to update the store.
   */
  const optimisticDeleteConversation = useCallback(
    async (conversationId: string) => {
      if (!conversationId) return;

      // Clear active conversation if this one was active
      const activeId = useCedarStore.getState().activeConversationId;
      if (activeId === conversationId) {
        useCedarStore.getState().setActiveConversationId(null);
      }

      // 1. Call server directly (no optimistic remove since conversationsSlice doesn't support it)
      try {
        await deleteConversationServer({ id: conversationId });

        // 2. Non-blocking invalidation - CRMDataSync will refetch and update stores
        queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        posthog.capture('crm_conversation_deleted');
      } catch (error) {
        // On error, just show error (no need to revert since we didn't update optimistically)
        toast.error('Failed to delete conversation');
        console.error('[Conversations] Error deleting conversation:', error);
      }
    },
    [deleteConversationServer, queryClient, trpc],
  );

  /**
   * Optimistically update multiple conversations at once
   */
  const optimisticBulkUpdate = useCallback(
    async (
      conversationIds: string[],
      updates: {
        status?: string;
        priority?: string;
      },
    ) => {
      if (!conversationIds.length) return;

      // Get current conversations from conversationsSlice
      const conversations = useCedarStore.getState().conversations;
      const optimisticConversations: Record<string, HydratedConversation> = {};

      conversationIds.forEach((id) => {
        const current = conversations[id];
        if (current) {
          optimisticConversations[id] = {
            ...current.data,
            conversation: {
              ...current.data.conversation,
              ...updates,
              updatedAt: new Date(),
            },
          };
        }
      });

      // Same mask as the single-conversation path — a bulk status change is the case most
      // likely to race a list refetch, because it is fired from the list itself.
      for (const id of conversationIds) markConversationFieldsPending(id, updates);

      // 1. Optimistic update: Update conversationsSlice immediately
      setConversations(optimisticConversations);

      // Also update React Query cache for each conversation to prevent ConversationDataSync from overwriting
      conversationIds.forEach((id) => {
        const optimisticData = optimisticConversations[id];
        if (optimisticData) {
          const conversationQueryKey=[redacted] id });
          queryClient.setQueryData(conversationQueryKey, optimisticData);
        }
      });

      // 2. Call server for each conversation
      try {
        await Promise.all(
          conversationIds.map((id) =>
            updateConversationServer({
              id,
              ...updates,
            }),
          ),
        );

        posthog.capture('crm_bulk_update', { count: conversationIds.length });
      } catch (error) {
        // Release the masks before refetching — see the single-conversation path.
        for (const id of conversationIds) {
          clearConversationFieldsPending(id, Object.keys(updates));
        }

        // On error, invalidate to trigger refetch
        await queryClient.invalidateQueries({
          queryKey=[redacted],
        });

        // Also invalidate individual conversation queries
        conversationIds.forEach((id) => {
          queryClient.invalidateQueries({
            queryKey=[redacted] id }),
          });
        });

        toast.error('Failed to update conversations');
        console.error('[Conversations] Error bulk updating conversations:', error);
      }
    },
    [setConversations, updateConversationServer, queryClient, trpc],
  );

  return {
    optimisticUpdateConversation,
    optimisticUpdateStatus,
    optimisticUpdatePriority,
    optimisticUpdateNextSteps,
    optimisticUpdateNextStepDate,
    optimisticUpdateNotes,
    optimisticUpdateCustomField,
    optimisticDeleteConversation,
    optimisticBulkUpdate,
  };
}