conversation-update-error.ts4.7 KBView on GitHub
/**
 * Human-readable failure copy for conversation field updates.
 *
 * The old handler did `toast.error(error.message)`, which for a severed connection
 * surfaced the raw fetch failure — a string naming `api.mail.cedarcopilot.com`. That told
 * the user nothing about what they were doing, and was actively misleading for the
 * AOP-change case: the write had usually already committed server-side and only the
 * connection died (see apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md).
 *
 * This maps an update failure to copy that names the field the user touched, says what to
 * do next, and never leaks an internal hostname.
 */

/** Update payload keys → what the user calls them in the UI. */
const FIELD_LABELS: Record<string, string> = {
  aopId: 'AOP',
  name: 'name',
  status: 'status',
  priority: 'priority',
  risk: 'risk',
  nextSteps: 'next steps',
  nextStepDate: 'next step date',
  statusOverview: 'status overview',
  objective: 'objective',
  dealValue: 'deal value',
  lastContactedAt: 'last contacted date',
  lastEmailAt: 'last email date',
  important: 'important flag',
  ownerUserId: 'owner',
};

/**
 * Anything that looks like a URL or a bare host — never worth showing a user.
 *
 * The host arm requires a final label that is alphabetic (a TLD), so a dotted VERSION in
 * an otherwise useful server message ("failed at 1.2.3") survives instead of being blanked
 * out along with the hostname it was meant to catch.
 */
const URL_OR_HOST =
  /\b(?:https?:\/\/\S+|[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}(?::\d+)?(?:\/\S*)?)/gi;

/**
 * Failures where the request never completed — the browser could not reach the API, or an
 * intermediary (CloudFront at 60s) cut the connection. Covers Chrome's "Failed to fetch",
 * Safari's "Load failed", Firefox's "NetworkError when attempting to fetch resource", and
 * explicit timeout/abort wording.
 *
 * The 5xx arm requires the number to be presented AS a status — labelled, or followed by
 * its reason phrase. A bare `502|503|504` alternation matched those digits anywhere: in an
 * id, a deal value, a row count. Misclassifying a real server error as "the connection
 * dropped" tells the user their change "may have saved" when the server said it didn't.
 */
const CONNECTION_FAILURE = new RegExp(
  [
    'failed to fetch',
    'load failed',
    'network\\s?error',
    'network request failed',
    'fetch failed',
    'err_(?:network|connection|timed_out)',
    'socket hang up',
    'timed? ?out',
    'timeout',
    'aborted',
    'econnreset',
    'bad gateway',
    'gateway time-?out',
    'service unavailable',
    // "status 503", "HTTP 502", "code: 504"
    '\\b(?:status|http|code)\\b\\s*:?\\s*50[234]\\b',
  ].join('|'),
  'i',
);

/** Turns the changed keys into a phrase like "AOP" or "status and priority". */
export function describeUpdatedFields(fields: string[]): string {
  const labels = fields.map((field) => FIELD_LABELS[field] ?? field);
  if (labels.length === 0) return 'conversation';
  if (labels.length === 1) return labels[0]!;
  if (labels.length === 2) return `${labels[0]} and ${labels[1]}`;
  return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
}

function isConnectionFailure(error: unknown): boolean {
  // A bare `TypeError` from fetch() is the canonical "request never left / never returned".
  if (error instanceof TypeError) return true;
  const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
  return CONNECTION_FAILURE.test(message);
}

/** Strips URLs/hostnames and collapses whitespace so server copy is safe to display. */
function sanitize(message: string): string {
  return message.replace(URL_OR_HOST, '').replace(/\s{2,}/g, ' ').trim();
}

/**
 * Builds the toast body for a failed conversation update.
 *
 * @param error  whatever the mutation rejected with
 * @param fields the payload keys the user was changing
 */
export function describeConversationUpdateError(error: unknown, fields: string[]): string {
  const subject = describeUpdatedFields(fields);

  if (isConnectionFailure(error)) {
    // Deliberately does not claim the change was lost — for slow server-side work the
    // write frequently committed before the connection dropped, and telling the user it
    // failed outright would be wrong.
    return `Couldn't confirm the ${subject} change — the connection dropped. It may have saved; refresh to check.`;
  }

  const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
  const cleaned = sanitize(raw);

  // Fall back to generic copy when the server message is empty or was nothing but a URL.
  if (!cleaned) return `Couldn't update the ${subject}. Please try again.`;

  return `Couldn't update the ${subject}: ${cleaned}`;
}