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.';
}