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}`;
}