use-bound-cell-write.ts6.6 KBView on GitHub
'use client';

/**
 * The write half of a bound cell: a cell string in, a CRM mutation out.
 *
 * The grid never talks to `crm_conversations` in its own words. It calls the SAME two
 * mutations the deal header and the conversation row call — `crm.updateConversation` and
 * `crm.upsertWorkingMemory` — so the ownership check, the timeline row, the external-CRM push
 * and the bound-cell invalidation all happen exactly as they do everywhere else. What is new
 * here is only the coercion: every cell is a string, and a `dealValue` is a number.
 *
 * Failure is a REVERT, not a toast alone. The caller writes the new value into the Y.Doc
 * before this runs (the paint the user is waiting on), so a rejected mutation has to put the
 * old one back — otherwise the grid keeps showing a value the deal never took.
 */

import { useCallback, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { inferRouterInputs } from '@trpc/server';
import type { AppRouter } from '@zero/server/trpc';
import { toast } from 'sonner';

import { useTRPC } from '@/providers/query-provider';
import { isCheckedValue, parseCellDate } from './cell-values';
import type { BoundWriteTarget, ConversationWriteKey } from './bound-writes';

/**
 * How a bound DATE cell is STORED — the server's own `formatBindingValue`, which slices an
 * ISO string rather than honouring `column.format`. The editor has to read and write that
 * form, not the column's, or the first recompute would rewrite what the user just picked.
 */
export const BOUND_DATE_FORMAT = 'yyyy-MM-dd';

type ConversationPatch = Omit<inferRouterInputs<AppRouter>['crm']['updateConversation'], 'id'>;

/**
 * One cell string → the conversation patch it means, or why it is not one.
 *
 * Coercion and field mapping in a single exhaustive switch, deliberately: they are the same
 * decision seen twice, and splitting them is how a date picker ends up writing into a field
 * the route reads as text. The compiler checks each arm against the real mutation input, so a
 * renamed field fails to build rather than failing in front of a customer.
 */
function conversationPatch(
  key=[redacted],
  raw: string,
): { patch: ConversationPatch } | { error: string } {
  const trimmed = raw.trim();
  // Emptying a cell CLEARS the field — each of these is nullable on the conversation, so
  // "I deleted the text" has somewhere honest to land.
  switch (key) {
    case 'name':
      // `crm_conversations.name` is NOT NULL, and the update route reads a null name as
      // "leave it alone" — so an emptied name cell would silently do nothing at all.
      if (!trimmed) return { error: 'A deal needs a name' };
      return { patch: { name: trimmed } };
    case 'status':
      return { patch: { status: trimmed || null } };
    case 'priority':
      return { patch: { priority: trimmed || null } };
    case 'risk':
      return { patch: { risk: trimmed || null } };
    case 'nextSteps':
      return { patch: { nextSteps: trimmed || null } };
    case 'statusOverview':
      return { patch: { statusOverview: trimmed || null } };
    case 'important':
      return { patch: { important: isCheckedValue(trimmed) } };
    case 'dealValue': {
      if (!trimmed) return { patch: { dealValue: null } };
      // A currency symbol and thousands separators are what a person types into a deal value.
      const parsed = Number(trimmed.replace(/[$,\s]/g, ''));
      if (!Number.isFinite(parsed)) return { error: `'${raw}' is not a number` };
      return { patch: { dealValue: parsed } };
    }
    case 'nextStepDate': {
      if (!trimmed) return { patch: { nextStepDate: null } };
      const parsed = parseCellDate(trimmed, BOUND_DATE_FORMAT);
      if (!parsed) return { error: `'${raw}' is not a date` };
      return { patch: { nextStepDate: parsed } };
    }
  }
}

/**
 * Sends one bound cell's value to the CRM. Resolves when the write has SETTLED — refused,
 * failed or applied — and never rejects: a failure is already a toast and a revert here.
 *
 * Awaitable because a caller writing several cells of one gesture has to be able to order
 * them: two writes to the same deal must not race the other's optimistic lock. See
 * `sendGroupedWrites` in `use-grid-commands.ts`.
 */
export type BoundCellWriter = (
  target: BoundWriteTarget,
  value: string,
  /** Put the previous cell value back. Called on a refused value and on a failed write. */
  revert: () => void,
) => Promise<void>;

export function useBoundCellWrite(): BoundCellWriter {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { mutateAsync: updateConversation } = useMutation(
    trpc.crm.updateConversation.mutationOptions(),
  );
  const { mutateAsync: upsertField } = useMutation(trpc.crm.upsertWorkingMemory.mutationOptions());

  // Read at call time. The returned writer must be STABLE — it is threaded through the
  // memoized row components, and a fresh identity each render would re-render every visible
  // row whenever anything else in the grid changed.
  const handlers = useRef({ updateConversation, upsertField, queryClient, trpc });
  handlers.current = { updateConversation, upsertField, queryClient, trpc };

  return useCallback(async (target, value, revert) => {
    const { updateConversation: update, upsertField: upsert, queryClient: qc, trpc: t } =
      handlers.current;

    let write: Promise<unknown>;
    if (target.write.type === 'customField') {
      // A user-defined CRM field is working memory: its value is a string, and clearing it
      // is the empty one.
      write = upsert({
        conversationId: target.conversationId,
        name: target.write.name,
        value: value.trim(),
      });
    } else {
      const result = conversationPatch(target.write.key, value);
      if ('error' in result) {
        toast.error(`${result.error} — the cell was left as it was.`);
        revert();
        return;
      }
      write = update({ id: target.conversationId, ...result.patch });
    }

    try {
      await write;
      // The bound cell itself is refreshed server-side — the CRM write invalidates it. This
      // is for the OTHER surfaces on screen: a deal open in a side panel, the pipeline
      // behind the file, both of which read the conversation through these queries.
      void qc.invalidateQueries({
        queryKey=[redacted] id: target.conversationId }),
      });
      void qc.invalidateQueries({ queryKey=[redacted] });
    } catch (error: unknown) {
      toast.error(error instanceof Error ? error.message : 'Could not update the deal');
      revert();
    }
  }, []);
}