diff --git a/src/lib/smartsheet.ts b/src/lib/smartsheet.ts index 705fdec..37d1f2a 100644 --- a/src/lib/smartsheet.ts +++ b/src/lib/smartsheet.ts @@ -137,39 +137,58 @@ async function request( * - 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/` — the real ID. + * Works against the REST API. + * - `https://app.smartsheet.com/sheets/` — a 30+ char + * opaque slug for sharing with non-users. The REST API does NOT + * accept these; calling `/2.0/sheets/` 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, })), }; }