cell-output.ts18.7 KBView on GitHub
/**
 * What an OUTPUT cell holds, and how it survives being a string.
 *
 * ── Why the cell, and not a task ──
 *
 * The obvious move was to reuse the Tasks output axis end to end: create a `user_tasks` row per
 * cell and put the artifact on its `task_output`. That is where a Slack draft lives today, and it
 * comes with a send button for free. It is also wrong for a table: a 40-row fan-out would put 40
 * rows in the agenda that duplicate, one for one, the 40 rows the user is already looking at. The
 * table IS the review surface; a second one is noise.
 *
 * And nothing is lost by not having a task. A Slack "draft" has no existence on Slack's side — it
 * is data waiting for a send call — so the artifact genuinely IS its payload. Sending goes
 * straight to `integrations.slack.sendMessage`, which takes the payload and no task.
 *
 * The SHAPE is still the Tasks one (`TaskOutput` in aop-schema.ts), field for field, so a cell can
 * be lifted onto a task later without a translation layer.
 *
 * ── Why JSON, in a cell that is a string ──
 *
 * Every cell is a string, and the pipe mirror is the authoritative serialization
 * (`writeFileAsYjs` rebuilds the Y.Doc FROM it). `escapeCellValue` already pins `|`, newlines and
 * edge whitespace losslessly, so JSON round-trips through the mirror byte for byte — and a model
 * emits JSON far more reliably than any bespoke grammar we could invent for it.
 *
 * Parsing is TOLERANT on purpose. A cell is not validated at write time (the `table` tool writes
 * strings), so anything malformed has to render as what it is — text — rather than throw inside a
 * virtualized row and take the grid down.
 */

import { TABLE_OUTPUT_KIND, type TableOutputKind, isTableOutputKind } from '@zero/server/table';

/**
 * A produced Slack message. Mirrors `SlackTaskOutput` minus `draftId`, which only exists to tie a
 * draft to a task — the thing this deliberately does not have.
 */
export interface SlackCellOutput {
  kind: typeof TABLE_OUTPUT_KIND.SLACK;
  channelId?: string;
  /** Without the `#`. Carried so a cell reads as something without a Slack round-trip. */
  channelName?: string;
  workspaceId?: string;
  /** Set to reply inside a thread rather than post to the channel. */
  threadTs?: string;
  /** Slack mrkdwn. `<@U123>` mentions are live once sent. */
  message?: string;
  /** ISO timestamp, written when the message is actually sent. Presence IS "sent". */
  sentAt?: string;
  /** Slack's `ts` for the posted message, so a sent cell can link to it. */
  sentTs?: string;
}

/**
 * A LinkedIn DM, drafted in a cell.
 *
 * Mirrors `LinkedinTaskOutput` (aop-schema.ts) field for field, plus a `recipientName` the
 * Slack payload's `channelName` is the precedent for: without it a column of LinkedIn drafts
 * is a column of Unipile chat ids, and the cell cannot say who it is to without a round trip.
 *
 * ── Two ways to address a person, because LinkedIn genuinely has two ──
 *
 * `chatId` names a conversation that already exists. `attendeeProviderId` names a PERSON, and
 * is what a fan-out over a lead list has — those rows have no chat yet, and requiring one
 * would make the column fillable only for people you have already spoken to, which is the
 * opposite of the case it is for. `linkedin.messaging.sendDm` accepts either.
 */
export interface LinkedinCellOutput {
  kind: typeof TABLE_OUTPUT_KIND.LINKEDIN;
  /** The Unipile account sending. Required to send: it is which of your seats this goes from. */
  unipileAccountId?: string;
  /** An existing Unipile chat. */
  chatId?: string;
  /** The counterpart's LinkedIn provider id, for a first message with no chat behind it. */
  attendeeProviderId?: string;
  /** Display only — who this is to, so a cell reads as something. */
  recipientName?: string;
  message?: string;
  /** ISO timestamp, written when the message is actually sent. Presence IS "sent". */
  sentAt?: string;
}

/** A WhatsApp message. Same shape, and `phoneE164` is its second address — see LinkedIn above. */
export interface WhatsappCellOutput {
  kind: typeof TABLE_OUTPUT_KIND.WHATSAPP;
  unipileAccountId?: string;
  chatId?: string;
  /** E.164, e.g. `+14155551234`. Starts a chat with someone there is no thread with yet. */
  phoneE164?: string;
  recipientName?: string;
  message?: string;
  sentAt?: string;
}

export interface EmailCellOutput {
  kind: typeof TABLE_OUTPUT_KIND.EMAIL;
  to?: string[];
  subject?: string;
  body?: string;
  draftId?: string;
  threadId?: string;
  sentAt?: string;
}

export interface FileCellOutput {
  kind: typeof TABLE_OUTPUT_KIND.FILE;
  documentId?: string;
  documentPath?: string;
  title?: string;
  /** Present for uniformity across the union, so `sentAt` can be stamped without narrowing. */
  sentAt?: string;
}

/** The kinds with no bespoke payload yet — carried so the column type is complete, not partial. */
export interface GenericCellOutput {
  kind: Exclude<
    TableOutputKind,
    | typeof TABLE_OUTPUT_KIND.SLACK
    | typeof TABLE_OUTPUT_KIND.EMAIL
    | typeof TABLE_OUTPUT_KIND.FILE
    | typeof TABLE_OUTPUT_KIND.LINKEDIN
    | typeof TABLE_OUTPUT_KIND.WHATSAPP
  >;
  message?: string;
  sentAt?: string;
}

export type CellOutput =
  | SlackCellOutput
  | EmailCellOutput
  | FileCellOutput
  | LinkedinCellOutput
  | WhatsappCellOutput
  | GenericCellOutput;

/** The kinds whose payload is a message to a person on a chat channel. */
export type ChatCellOutput = SlackCellOutput | LinkedinCellOutput | WhatsappCellOutput;

/**
 * Read an output cell.
 *
 * `null` means "this cell does not hold an output" — empty, prose a human typed, a half-written
 * blob. Every caller renders that as plain text, which is the only honest thing to do with it.
 */
export function parseCellOutput(value: string): CellOutput | null {
  const trimmed = value.trim();
  // Cheap reject before `JSON.parse`, because this runs per visible cell per render.
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
  let raw: unknown;
  try {
    raw = JSON.parse(trimmed);
  } catch {
    return null;
  }
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
  // `raw` is already narrowed to a non-null, non-array object, so indexing it needs no cast —
  // `Reflect.get` reads an unknown key off it and yields `unknown`, which is the truth.
  const read = (key=[redacted] unknown => Reflect.get(raw, key);
  const kind = read('kind');
  if (!isTableOutputKind(kind)) return null;

  // Field-by-field, because the JSON is untrusted (a model wrote it, or an import did) and a
  // `channelId` that is secretly a number would reach the Slack API as one. The accumulator is
  // typed as the OPTIONAL half of the union — every member's fields are optional strings (plus
  // `to`), so one shape covers them all and the union is completed by `kind` on return. That is
  // what lets this return a real `CellOutput` with no assertion anywhere in the function.
  const fields: Partial<Record<(typeof STRING_FIELDS)[number], string>> & { to?: string[] } = {};
  for (const key of STRING_FIELDS) {
    const value = read(key);
    if (typeof value === 'string' && value !== '') fields[key] = value;
  }
  const to = read('to');
  if (Array.isArray(to)) {
    const recipients = to.filter((v): v is string => typeof v === 'string' && v !== '');
    if (recipients.length > 0) fields.to = recipients;
  }
  return { ...fields, kind };
}

const STRING_FIELDS = [
  'channelId',
  'channelName',
  'workspaceId',
  'threadTs',
  'message',
  'sentAt',
  'sentTs',
  'subject',
  'body',
  'draftId',
  'threadId',
  'documentId',
  'documentPath',
  'title',
  // LinkedIn / WhatsApp
  'unipileAccountId',
  'chatId',
  'attendeeProviderId',
  'phoneE164',
  'recipientName',
] as const;

/**
 * Write an output cell.
 *
 * Keys with no value are dropped rather than emitted as `null`, so an unproduced output is
 * `{"kind":"slack"}` — the same "intent declared, artifact not yet made" that `{ kind }` alone
 * means on a task.
 */
export function formatCellOutput(output: CellOutput): string {
  const compact: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(output)) {
    if (value === undefined || value === null || value === '') continue;
    if (Array.isArray(value) && value.length === 0) continue;
    compact[key] = value;
  }
  return JSON.stringify(compact);
}

/** True once the artifact has actually gone out. Presence of the timestamp IS the signal. */
export function isOutputSent(output: CellOutput): boolean {
  return 'sentAt' in output && !!output.sentAt;
}

/** True for the kinds whose payload is a message to a person on a chat channel. */
export function isChatCellOutput(output: CellOutput): output is ChatCellOutput {
  return (
    output.kind === TABLE_OUTPUT_KIND.SLACK ||
    output.kind === TABLE_OUTPUT_KIND.LINKEDIN ||
    output.kind === TABLE_OUTPUT_KIND.WHATSAPP
  );
}

/** One line of what this cell holds, for the collapsed display. */
export function summarizeCellOutput(output: CellOutput): string {
  if (output.kind === TABLE_OUTPUT_KIND.EMAIL) {
    return output.subject?.trim() || output.body?.trim() || 'No draft yet';
  }
  if (output.kind === TABLE_OUTPUT_KIND.FILE) {
    return output.title?.trim() || output.documentPath?.trim() || 'No file yet';
  }
  if (isChatCellOutput(output)) return output.message?.trim() || 'No message yet';
  return output.message?.trim() || 'Nothing yet';
}

/**
 * The destination, when the kind has one — `#channel` for Slack, recipients for email, the
 * counterpart for a DM.
 *
 * A LinkedIn or WhatsApp cell falls back to its raw address rather than to nothing: a Unipile
 * chat id is not a name, but "this is going somewhere I can identify" is a materially different
 * state from "this has no recipient at all", and the pill is where that difference shows.
 */
export function describeCellOutputTarget(output: CellOutput): string | null {
  if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
    return output.channelName ? `#${output.channelName}` : (output.channelId ?? null);
  }
  if (output.kind === TABLE_OUTPUT_KIND.EMAIL) {
    return output.to?.join(', ') ?? null;
  }
  if (output.kind === TABLE_OUTPUT_KIND.LINKEDIN) {
    return output.recipientName?.trim() || output.chatId || output.attendeeProviderId || null;
  }
  if (output.kind === TABLE_OUTPUT_KIND.WHATSAPP) {
    return output.recipientName?.trim() || output.phoneE164 || output.chatId || null;
  }
  return null;
}

/**
 * Output kinds that ARE a messaging channel, and the badge each maps to.
 *
 * `file` and `calendar` are outputs with no channel, so they get no brand badge rather than
 * `ChannelBadgeIcon`'s email fallback — a Gmail mark on a generated file would be a lie. Keyed by
 * `TableOutputKind` so a kind added later is a compile error here if it is spelled wrong, rather
 * than silently badge-less at runtime.
 */
export const OUTPUT_BADGE_CHANNEL: Partial<
  Record<TableOutputKind, 'slack' | 'email' | 'linkedin' | 'whatsapp'>
> = {
  slack: 'slack',
  email: 'email',
  linkedin: 'linkedin',
  whatsapp: 'whatsapp',
};

/**
 * The free-text body of an output, under whatever key its kind actually stores it — `message`
 * for Slack, `body` for email — or `null` for a kind with no prose at all.
 *
 * The pair exists so an editor never has to guess. Writing `message` onto an email payload
 * type-checks (the union is wide) and is silently meaningless: `summarizeCellOutput` reads
 * `subject`/`body` and would never show it back.
 */
export function cellOutputText(output: CellOutput): string | null {
  if (output.kind === TABLE_OUTPUT_KIND.EMAIL) return output.body ?? '';
  if (output.kind === TABLE_OUTPUT_KIND.FILE) return null;
  return output.message ?? '';
}

/** The setter half of `cellOutputText`. A kind with no body is returned unchanged. */
export function withCellOutputText(output: CellOutput, text: string): CellOutput {
  if (output.kind === TABLE_OUTPUT_KIND.EMAIL) return { ...output, body: text };
  if (output.kind === TABLE_OUTPUT_KIND.FILE) return output;
  return { ...output, message: text };
}

/**
 * What a cell needs in hand before it can be sent — one variant per channel, every field
 * REQUIRED.
 *
 * This is a resolved payload rather than a `boolean` or a narrowed union member, and that is
 * the point: the stored types have every field optional (a half-drafted cell is a real state),
 * so a call site narrowed to `SlackCellOutput` still had to reach for `workspaceId!` to build
 * the mutation. Three non-null assertions on a value a model wrote is exactly the shape that
 * ships `undefined` to an API. Resolving once, here, means the send site holds a value that
 * cannot be missing anything.
 */
export type SendableCellOutput =
  | { channel: 'slack'; workspaceId: string; channelId: string; message: string; threadTs?: string }
  | {
      channel: 'linkedin';
      unipileAccountId: string;
      text: string;
      chatId?: string;
      attendeeProviderId?: string;
    }
  | {
      channel: 'whatsapp';
      unipileAccountId: string;
      text: string;
      chatId?: string;
      phoneE164?: string;
    };

/**
 * The send this cell is ready for, or null.
 *
 * Null covers three different "not yet"s and deliberately does not distinguish them — already
 * sent, no recipient resolved, nothing written. The editor renders one disabled button whose
 * tooltip names what is missing; the states differ in prose, not in what may happen next.
 */
export function sendableCellOutput(output: CellOutput): SendableCellOutput | null {
  // Only a message to a person can be sent from a cell. An email draft lives in Gmail and a
  // file is already made — neither has a "send" this surface could perform.
  if (!isChatCellOutput(output) || isOutputSent(output)) return null;
  const message = output.message?.trim() ?? '';
  if (!message) return null;

  if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
    // A workspace AND a channel: a channel id alone does not say which Slack it belongs to,
    // and a NAME is not an address — `#growth` cannot be posted to.
    if (!output.workspaceId || !output.channelId) return null;
    return {
      channel: 'slack',
      workspaceId: output.workspaceId,
      channelId: output.channelId,
      message,
      ...(output.threadTs ? { threadTs: output.threadTs } : {}),
    };
  }
  if (output.kind === TABLE_OUTPUT_KIND.LINKEDIN) {
    // The seat is not optional — it is which of your accounts this goes from — and there has
    // to be somewhere for it to land: an existing chat, or a person to open one with.
    if (!output.unipileAccountId) return null;
    if (!output.chatId && !output.attendeeProviderId) return null;
    return {
      channel: 'linkedin',
      unipileAccountId: output.unipileAccountId,
      text: message,
      ...(output.chatId ? { chatId: output.chatId } : {}),
      ...(output.attendeeProviderId ? { attendeeProviderId: output.attendeeProviderId } : {}),
    };
  }
  if (output.kind === TABLE_OUTPUT_KIND.WHATSAPP) {
    if (!output.unipileAccountId) return null;
    if (!output.chatId && !output.phoneE164) return null;
    return {
      channel: 'whatsapp',
      unipileAccountId: output.unipileAccountId,
      text: message,
      ...(output.chatId ? { chatId: output.chatId } : {}),
      ...(output.phoneE164 ? { phoneE164: output.phoneE164 } : {}),
    };
  }
  return null;
}

/** Why the Send button is disabled, in the words of the channel it would have sent on. */
export function describeUnsendable(output: CellOutput): string {
  if (isOutputSent(output)) return 'Already sent';
  if (!output.kind || !isChatCellOutput(output)) return 'This kind cannot be sent from a table';
  if (!output.message?.trim()) return 'Needs a message';
  if (output.kind === TABLE_OUTPUT_KIND.SLACK) {
    return 'Needs a resolved channel and workspace';
  }
  if (!output.unipileAccountId) return 'Needs the account to send from';
  return output.kind === TABLE_OUTPUT_KIND.LINKEDIN
    ? 'Needs a chat or a LinkedIn profile id'
    : 'Needs a chat or a phone number';
}

// ── Mentions, for an editable surface ─────────────────────────────────────────
//
// A stored mention is `<@U0A152K8J5V|brock>` — correct for Slack, unreadable for a person. The
// editor therefore shows `@brock` and converts back on the way in.
//
// This is deliberately NOT `slackTextToPlain`: that also strips `*bold*`, `~strike~` and code
// fences, which is right for a read-only preview and catastrophic for an editable box — every
// decode/encode round trip would silently eat the author's formatting. These two touch mentions
// and nothing else, so anything that is not a mention survives byte for byte.

const MENTION_TOKEN=[redacted];
/** Splits text into `<…>` tokens and the plain runs between them, so encoding skips tokens. */
const TOKEN_OR_TEXT = /<[^>]*>|[^<]+/g;

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** `<@U0A…|brock> hi` → `@brock hi`. Mentions only; every other character is untouched. */
export function decodeMentionsForEditing(raw: string): string {
  return raw.replace(MENTION_TOKEN, (_match, id: string, label?: string) => `@${label || id}`);
}

/**
 * The inverse, against the mentions the value ALREADY had.
 *
 * Only handles that were real mentions before the edit are re-encoded — a name typed from
 * scratch has no user id to become, and inventing one would ping the wrong person. Those stay
 * plain text, which is exactly what Slack would render them as. The `@` menu is the way to add
 * a new one, and it inserts a full token that this leaves alone.
 *
 * Matched from the KNOWN handles rather than from a generic `@\w+` pattern, because a Slack
 * label is a display name and may contain spaces: `<@U123|Brock Smith>` decodes to
 * `@Brock Smith`, which `@[\w.-]+` could only see as `@Brock`. That missed the map, the mention
 * degraded to plain text, and the ping was silently dropped on the next keystroke. Longest
 * handle first, so `@Brock Smith` is never eaten by a shorter `@Brock` that also exists.
 */
export function encodeMentionsFromEditing(text: string, original: string): string {
  const byHandle = new Map<string, string>();
  for (const match of original.matchAll(MENTION_TOKEN)) {
    byHandle.set(`@${match[2] || match[1]}`, match[0]);
  }
  if (byHandle.size === 0) return text;

  const handles = [...byHandle.keys()].sort((a, b) => b.length - a.length);
  // The trailing guard stops `@brock` from matching inside `@brockton`; a following `.` or `-`
  // is allowed through because sentence punctuation is far commoner than a longer handle that
  // is not itself in the map.
  const pattern = new RegExp(`(?:${handles.map(escapeRegExp).join('|')})(?![\\w])`, 'g');

  return text.replace(TOKEN_OR_TEXT, (segment) =>
    segment.startsWith('<')
      ? segment
      : segment.replace(pattern, (handle) => byHandle.get(handle) ?? handle),
  );
}