fix(smartsheet): detect share-link slugs and guard malformed API responses
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s

When an admin pastes a Smartsheet share-link URL (or its bare slug) into
the Water Log Smartsheet config, the REST API returns a 2xx with a body
that lacks the 'result' envelope. getSheetMeta then crashed with:

  Cannot read properties of undefined (reading 'id')

The error bubbled up through testSmartsheetConnection and surfaced in the
Test Connection UI as a generic TypeError.

Two-part fix in src/lib/smartsheet.ts:

1. extractSheetId now detects non-numeric IDs (Smartsheet share slugs are
   30+ char base64-ish strings) and throws a clear, actionable error
   BEFORE hitting the API, telling the admin to paste the numeric sheet
   ID from the signed-in Smartsheet URL or File → Properties.

2. getSheetMeta has a defensive guard (if (!data?.result || data.result.id
   == null)) that throws a SmartsheetApiError(502, ...) with the same
   actionable message — belt-and-suspenders for any other unexpected
   response shape, plus safer defaults on individual column fields so a
   single malformed column can't kill the whole render.

User's failing input was:
  https://app.smartsheet.com/sheets/V4XgwvVJqFjcxQmQMrgwm77WjCP7hpjqqW82RRR1

Their real numeric sheet ID is 782660409446276 (now accepted).
This commit is contained in:
Nora
2026-07-03 16:18:47 -06:00
parent a470b6fe9d
commit 71e1792ee3
+59 -23
View File
@@ -137,39 +137,58 @@ async function request<T>(
* - A bare ID: "123456789012345" * - A bare ID: "123456789012345"
* - A URL: "https://app.smartsheet.com/sheets/abc123?..." * - A URL: "https://app.smartsheet.com/sheets/abc123?..."
* *
* Smartsheet share URLs include an opaque slug (e.g. "abc123"). We * Smartsheet has TWO kinds of sheet URLs:
* can't resolve that slug without an API call (the sheet meta call * - `https://app.smartsheet.com/sheets/<numeric-id>` — the real ID.
* will fail with 404 anyway), so we just take the last path segment * Works against the REST API.
* and pass it as the sheet ID. If the user pasted a slug URL, the * - `https://app.smartsheet.com/sheets/<share-slug>` — a 30+ char
* "Test Connection" UI will surface the 404 and prompt for a numeric * opaque slug for sharing with non-users. The REST API does NOT
* ID instead. (This matches what most Smartsheet integrations do.) * accept these; calling `/2.0/sheets/<slug>` returns a 2xx with
* an empty/malformed body, which used to crash our parser with
* "Cannot read properties of undefined (reading 'id')". We detect
* that case here and surface a clear, actionable error so the
* admin pastes the numeric ID instead.
*
* The numeric ID can be found in the sheet's URL once the user is
* signed in to Smartsheet, or via File → Properties in the Smartsheet
* UI.
*/ */
export function extractSheetId(input: string): string { export function extractSheetId(input: string): string {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) { if (!trimmed) {
throw new Error("Sheet ID or URL is required"); throw new Error("Sheet ID or URL is required");
} }
// Plain numeric ID // Plain numeric ID — short-circuit.
if (/^\d+$/.test(trimmed)) return trimmed; if (/^\d+$/.test(trimmed)) return trimmed;
// URL form // URL form — pull the segment after "sheets", or fall back to the
// last path segment.
let candidate = trimmed;
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
const segments = url.pathname.split("/").filter(Boolean); const segments = url.pathname.split("/").filter(Boolean);
// Find the segment after "sheets"
const sheetsIdx = segments.findIndex((s) => s === "sheets"); const sheetsIdx = segments.findIndex((s) => s === "sheets");
if (sheetsIdx >= 0 && segments[sheetsIdx + 1]) { if (sheetsIdx >= 0 && segments[sheetsIdx + 1]) {
return segments[sheetsIdx + 1]; candidate = segments[sheetsIdx + 1];
} } else if (segments.length > 0) {
// Last segment as a fallback candidate = segments[segments.length - 1] ?? trimmed;
if (segments.length > 0) {
return segments[segments.length - 1];
} }
} catch { } catch {
// Not a URL — fall through // Not a URL — leave `candidate` as the original input.
} }
// Treat as opaque ID; API will surface 404 if it's not numeric.
return trimmed; // The Smartsheet REST API only accepts numeric IDs. Share-link slugs
// look like "V4XgwvVJqFjcxQmQMrgwm77WjCP7hpjqqW82RRR1" (30+ chars,
// base64-ish). Detect and reject them with a clear message BEFORE
// hitting the API.
if (!/^\d+$/.test(candidate)) {
throw new Error(
"That looks like a Smartsheet share-link slug, not a numeric sheet ID. " +
"Open the sheet in Smartsheet (signed in) and copy the numeric ID from the URL — " +
"or use File → Properties to find it. " +
`Got: "${candidate.slice(0, 12)}${candidate.length > 12 ? "…" : ""}"`,
);
}
return candidate;
} }
/** /**
@@ -206,14 +225,31 @@ export async function getSheetMeta(
token, token,
); );
// Defensive: Smartsheet occasionally returns a 2xx with a body that
// lacks the `result` envelope (e.g. when a non-numeric / share-slug
// sheet ID is passed — the API doesn't reject it cleanly). Without
// this guard we'd crash with "Cannot read properties of undefined
// (reading 'id')" deep in the column-mapping render. `extractSheetId`
// catches the common share-slug case up front; this guard is the
// belt-and-suspenders for anything else.
if (!data?.result || data.result.id == null) {
throw new SmartsheetApiError(
502,
`Smartsheet returned an unexpected response for sheet ${sheetId} ` +
`(missing result.id). If you pasted a Smartsheet share-link URL, ` +
`paste the numeric sheet ID instead — open the sheet in Smartsheet ` +
`while signed in and copy the number from the address bar.`,
);
}
return { return {
id: String(data.result.id), id: String(data.result.id),
name: data.result.name, name: data.result.name ?? "(unnamed sheet)",
columns: data.result.columns.map((c) => ({ columns: (data.result.columns ?? []).map((c) => ({
id: String(c.id), id: c.id != null ? String(c.id) : "",
title: c.title, title: c.title ?? "",
type: c.type, type: c.type ?? "TEXT_NUMBER",
index: c.index, index: c.index ?? 0,
})), })),
}; };
} }