fix(smartsheet): detect share-link slugs and guard malformed API responses
Deploy to route.crispygoat.com / deploy (push) Successful in 3m55s
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:
+59
-23
@@ -137,39 +137,58 @@ async function request<T>(
|
||||
* - A bare ID: "123456789012345"
|
||||
* - A URL: "https://app.smartsheet.com/sheets/abc123?..."
|
||||
*
|
||||
* Smartsheet share URLs include an opaque slug (e.g. "abc123"). We
|
||||
* can't resolve that slug without an API call (the sheet meta call
|
||||
* will fail with 404 anyway), so we just take the last path segment
|
||||
* and pass it as the sheet ID. If the user pasted a slug URL, the
|
||||
* "Test Connection" UI will surface the 404 and prompt for a numeric
|
||||
* ID instead. (This matches what most Smartsheet integrations do.)
|
||||
* Smartsheet has TWO kinds of sheet URLs:
|
||||
* - `https://app.smartsheet.com/sheets/<numeric-id>` — the real ID.
|
||||
* Works against the REST API.
|
||||
* - `https://app.smartsheet.com/sheets/<share-slug>` — a 30+ char
|
||||
* opaque slug for sharing with non-users. The REST API does NOT
|
||||
* 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 {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Sheet ID or URL is required");
|
||||
}
|
||||
// Plain numeric ID
|
||||
// Plain numeric ID — short-circuit.
|
||||
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 {
|
||||
const url = new URL(trimmed);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// Find the segment after "sheets"
|
||||
const sheetsIdx = segments.findIndex((s) => s === "sheets");
|
||||
if (sheetsIdx >= 0 && segments[sheetsIdx + 1]) {
|
||||
return segments[sheetsIdx + 1];
|
||||
}
|
||||
// Last segment as a fallback
|
||||
if (segments.length > 0) {
|
||||
return segments[segments.length - 1];
|
||||
candidate = segments[sheetsIdx + 1];
|
||||
} else if (segments.length > 0) {
|
||||
candidate = segments[segments.length - 1] ?? trimmed;
|
||||
}
|
||||
} 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,
|
||||
);
|
||||
|
||||
// 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 {
|
||||
id: String(data.result.id),
|
||||
name: data.result.name,
|
||||
columns: data.result.columns.map((c) => ({
|
||||
id: String(c.id),
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
index: c.index,
|
||||
name: data.result.name ?? "(unnamed sheet)",
|
||||
columns: (data.result.columns ?? []).map((c) => ({
|
||||
id: c.id != null ? String(c.id) : "",
|
||||
title: c.title ?? "",
|
||||
type: c.type ?? "TEXT_NUMBER",
|
||||
index: c.index ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user