From b061cdd28930cdb9103b5de49b1612e68a234703 Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 16:31:22 -0600 Subject: [PATCH] fix(smartsheet): read flat response shape for GET sheet, array result for POST row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Smartsheet API uses TWO different response shapes: - GET /2.0/sheets/{id} → flat: { id, name, columns, ... } - POST /2.0/sheets/{id}/rows → wrapped: { result: [{id, rowNumber}], resultCode, ... } Our code assumed both were wrapped in a 'result' object, which meant: - getSheetMeta read data.result.id → undefined on success. The defensive guard in 71e1792 caught it and returned 502 'missing result.id' instead of crashing — but no sheet has ever tested successfully, so the bug was invisible until a real token was wired up. - addRow read data.result.id → undefined, returning rowId='undefined' and rowNumber=undefined silently. Sync has never worked; any row insert would have created a row with bogus metadata in the queue. Fixes: - getSheetMeta now reads data.id / data.name / data.columns at the top level. Defensive guard checks for data?.id == null instead of data?.result?.id == null. - addRow now reads data.result[0].id / .rowNumber (array shape) and throws SmartsheetApiError(502) if the array is empty/missing. Verified end-to-end with the user's token against sheet 782660409446276 (Water Logs, 8 columns: Entry ID, Logged At, Headgate, Measurement, Unit, Irrigator, Notes). All column titles match our Water Log field names exactly, so the column-mapping dropdowns will auto-pair cleanly. --- src/lib/smartsheet.ts | 67 ++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/lib/smartsheet.ts b/src/lib/smartsheet.ts index 37d1f2a..9cacc05 100644 --- a/src/lib/smartsheet.ts +++ b/src/lib/smartsheet.ts @@ -207,16 +207,18 @@ export async function getSheetMeta( if (!token) throw new Error("token is required"); type ApiResponse = { - result: { + // NOTE: GET /2.0/sheets/{id} returns a FLAT response (id, name, + // columns at the top level), NOT wrapped in a `result` envelope. + // This is different from POST endpoints (which use { result: [...] }). + // See https://smartsheet.redoc.ly/#operation/getSheet + id: number; + name: string; + columns: Array<{ id: number; - name: string; - columns: Array<{ - id: number; - title: string; - type: string; - index: number; - }>; - }; + title: string; + type: string; + index: number; + }>; }; const data = await request( @@ -225,27 +227,26 @@ 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) { + // Defensive: if Smartsheet returns a 2xx without `id`, something is + // wrong (older API version, account-level issue, etc.). Without this + // guard we'd crash with "Cannot read properties of undefined (reading + // 'id')" deep in the column-mapping render. `extractSheetId` catches + // share-link slugs up front; this guard is belt-and-suspenders for + // anything else. + if (!data || data.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.`, + `(missing id at top level). 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 ?? "(unnamed sheet)", - columns: (data.result.columns ?? []).map((c) => ({ + id: String(data.id), + name: data.name ?? "(unnamed sheet)", + columns: (data.columns ?? []).map((c) => ({ id: c.id != null ? String(c.id) : "", title: c.title ?? "", type: c.type ?? "TEXT_NUMBER", @@ -278,8 +279,14 @@ export async function addRow( return v; }; + // NOTE: POST /2.0/sheets/{id}/rows wraps the created row(s) in + // `{ result: [{id, rowNumber}, ...] }` — different shape from the + // flat GET /2.0/sheets/{id} response. See + // https://smartsheet.redoc.ly/#operation/addRows type ApiResponse = { - result: { id: number; rowNumber: number }; + result: Array<{ id: number; rowNumber: number }>; + resultCode?: number; + message?: string; }; const data = await request( @@ -298,9 +305,17 @@ export async function addRow( }, ); + const row = data?.result?.[0]; + if (!row || row.id == null) { + throw new SmartsheetApiError( + 502, + `Smartsheet POST /sheets/${sheetId}/rows returned no row. ` + + `Response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } return { - rowId: String(data.result.id), - rowNumber: data.result.rowNumber, + rowId: String(row.id), + rowNumber: row.rowNumber, }; }