bound-writes.ts7.0 KBView on GitHub
/**
 * Which bound cells can be edited BACK to the deal they came from, and how.
 *
 * ── The rule ──
 *
 * A bound cell holds Cedar's last computed value for a binding path (`conversation.stage`,
 * `deal.nextStepDate`); the column holds the expression. Read-only was the honest v1 answer,
 * because a typed-over derived value survives only until the next recompute. It is not the
 * honest answer any more: a stage IS the deal's stage, and the grid is the surface a rep is
 * looking at when they decide to move it. So a bound cell whose source Cedar can write is
 * edited THROUGH the binding — the write lands on `crm_conversations`, and the same
 * invalidation hook every other CRM write fires (`invalidateTablesForConversations`)
 * recomputes the cell. The table never becomes the source of truth; it stays a view you can
 * type into.
 *
 * ── What makes a path writable ──
 *
 * Two things, both non-negotiable:
 *   1. there is a USER-authorized mutation for it (`crm.updateConversation`,
 *      `crm.upsertWorkingMemory`) — so the write is scoped and audited exactly as the same
 *      edit from the deal header would be; and
 *   2. writing it INVALIDATES the bound cell. Only `crm_conversations` and
 *      `crm_conversation_field_values` writes do (see the server's `table-invalidate.ts`),
 *      which is why company, person, doc and task bindings stay read-only here: a task's
 *      status has no invalidation hook, so a write-through would leave the cell showing the
 *      old value until something else happened to refresh it. A cell that lies is worse than
 *      a cell you cannot edit.
 *
 * The read whitelist itself is NOT restated here — `parseBindingPath` from
 * `@zero/server/table/bindings` is the server's own parser, so a binding written as
 * `deal.stage` (through a ref COLUMN) resolves identically on both sides.
 */

import { parseBindingPath } from '@zero/server/table/bindings';
import {
  REF_COLUMN_TYPES,
  TABLE_REF_TYPE,
  type TableColumn,
  type TableSchema,
} from '@zero/server/table';

import { parseCellRefs } from './cell-refs';

/**
 * What a writable field IS — which decides the editor the cell opens AND how its string is
 * coerced for the mutation. One decision rather than two, because the two drifting apart is
 * how a date picker ends up writing a value the server reads as text.
 */
export type BoundValueKind = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'priority';

/** The `crm.updateConversation` input keys a binding may target. */
export type ConversationWriteKey =
  | 'name'
  | 'status'
  | 'priority'
  | 'risk'
  | 'dealValue'
  | 'nextSteps'
  | 'nextStepDate'
  | 'statusOverview'
  | 'important';

/**
 * Bindable conversation fields that write back, keyed by the binding's TAIL.
 *
 * `stage` and `status` both land on `status`: `stage` is the PLAYBOOK.md name for it and the
 * one an agent writes, `status` is the column. They are the same field and must not become
 * two.
 */
const CONVERSATION_WRITES: Record<string, { key=[redacted]; kind: BoundValueKind }> = {
  name: { key=[redacted], kind: 'text' },
  stage: { key=[redacted], kind: 'status' },
  status: { key=[redacted], kind: 'status' },
  priority: { key=[redacted], kind: 'priority' },
  risk: { key=[redacted], kind: 'text' },
  dealValue: { key=[redacted], kind: 'number' },
  nextSteps: { key=[redacted], kind: 'text' },
  nextStepDate: { key=[redacted], kind: 'date' },
  statusOverview: { key=[redacted], kind: 'text' },
  important: { key=[redacted], kind: 'boolean' },
};

/** Exported for the test that pins this map inside the server's read whitelist. */
export const WRITABLE_CONVERSATION_TAILS = Object.keys(CONVERSATION_WRITES);

/** Where a bound cell's edit goes. */
export type BoundWrite =
  | { type: 'conversation'; key=[redacted] }
  /** A user-defined CRM field, written through `crm.upsertWorkingMemory`. */
  | { type: 'customField'; name: string };

export interface BoundWriteTarget {
  /** The deal the row points at — the ref the binding reads through. */
  conversationId: string;
  write: BoundWrite;
  kind: BoundValueKind;
  /** The path as written on the column, for the cell's hover text. */
  binding: string;
}

/**
 * The write target for a bound column in one row, or `null` when the cell stays read-only —
 * a non-conversation entity, a read-only field, or a row that references no deal at all.
 *
 * `cells` is the whole row: the ref a binding reads through lives in a DIFFERENT column of
 * the same row, which is why this cannot be decided from the cell alone.
 */
export function resolveBoundWrite(
  column: TableColumn,
  schema: TableSchema | null,
  cells: Record<string, string>,
): BoundWriteTarget | null {
  if (!column.binding || !schema) return null;
  const parsed = parseBindingPath(column.binding, schema);
  if ('error' in parsed) return null;
  const path = parsed.path;
  if (path.entity !== TABLE_REF_TYPE.CONVERSATION) return null;

  const conversationId = conversationRefForPath(path.sourceColumnKey, schema, cells);
  if (!conversationId) return null;

  if (path.customFieldKey !== undefined) {
    return {
      conversationId,
      write: { type: 'customField', name: path.customFieldKey },
      kind: 'text',
      binding: column.binding,
    };
  }

  const writable = CONVERSATION_WRITES[path.segments.join('.')];
  if (!writable) return null;
  return {
    conversationId,
    write: { type: 'conversation', key=[redacted] },
    kind: writable.kind,
    binding: column.binding,
  };
}

/**
 * The conversation id the binding reads through: the named column's ref, or — for the
 * `conversation.<field>` shorthand — the row's first conversation ref, in schema order.
 *
 * Deliberately the same rule as the server's `refIdsForRow`, so the cell the user edits and
 * the cell the refresh recomputes are about the same deal. Divergence here would be silent
 * and would write to the wrong record.
 */
function conversationRefForPath(
  sourceColumnKey=[redacted] | undefined,
  schema: TableSchema,
  cells: Record<string, string>,
): string | null {
  if (sourceColumnKey) {
    const ref = parseCellRefs(cells[sourceColumnKey] ?? '').find(
      (candidate) => candidate.refType === TABLE_REF_TYPE.CONVERSATION,
    );
    return ref?.refId ?? null;
  }
  for (const column of schema.columns) {
    if (!REF_COLUMN_TYPES.has(column.type)) continue;
    const ref = parseCellRefs(cells[column.key] ?? '').find(
      (candidate) => candidate.refType === TABLE_REF_TYPE.CONVERSATION,
    );
    if (ref) return ref.refId;
  }
  return null;
}

/**
 * Hover text for a bound cell — the binding path, plus what typing in it will do. "Why can't
 * I type here?" and "where does this go?" are the only two questions such a cell raises, and
 * which one it is depends entirely on whether the write lands anywhere.
 */
export function boundCellTitle(binding: string, writable: boolean): string {
  return writable
    ? `Linked to ${binding} — editing this cell updates the deal, everywhere.`
    : `Derived from ${binding} — Cedar keeps this up to date. ` +
        'Sever the column link to edit it by hand.';
}