/** * Smartsheet REST API wrapper (no SDK). * * We use direct REST instead of the official `smartsheet` npm SDK * because: * - The SDK has Node 22 compatibility issues (top-level `await` in * a CommonJS module) and pulls in ~1.4MB of transitive deps * (`request`, `request-promise`, `lodash`, etc.). * - REST covers our 3 endpoints cleanly: get sheet metadata, add a * row, search a sheet by column value. * - Smartsheet's own docs recommend REST for simple integrations. * * Endpoints used (all docs at https://smartsheet.redoc.ly): * - GET /2.0/sheets/{sheetId} — sheet metadata + columns * - POST /2.0/sheets/{sheetId}/rows — add a row * - GET /2.0/sheets/{sheetId} — full sheet (used for find-by-column) * * Auth: * - Bearer token via `Authorization: Bearer ` header. * - 300 requests/minute per token. Our drain caps at 50 rows per * cron run, well under the limit. * * Errors: * - `SmartsheetApiError` is thrown for any non-2xx response. It * carries `status` and `body` for the caller to log. */ import "server-only"; const BASE_URL = "https://api.smartsheet.com"; const DEFAULT_TIMEOUT_MS = Number.parseInt( process.env.SMARTSHEET_SYNC_TIMEOUT_MS ?? "8000", 10, ); export type SmartsheetColumn = { /** Numeric column ID, as a string (Smartsheet IDs overflow JS numbers). */ id: string; /** Display name in the sheet header row. */ title: string; /** Column type — TEXT_NUMBER, DATE, PICKLIST, etc. We treat all as text on write. */ type: string; /** Column index in the sheet (0-based). */ index: number; }; export type SmartsheetSheetMeta = { id: string; name: string; columns: SmartsheetColumn[]; }; export type SmartsheetCell = { columnId: string; value: string | number | boolean | null; }; export class SmartsheetApiError extends Error { readonly status: number; readonly body: string; readonly code: number | null; constructor(status: number, body: string, message?: string) { super( message ?? `Smartsheet API error ${status}: ${body.slice(0, 200)}${ body.length > 200 ? "…" : "" }`, ); this.name = "SmartsheetApiError"; this.status = status; this.body = body; // Smartsheet returns { errorCode, message, ... } on failure try { const parsed = JSON.parse(body) as { errorCode?: number }; this.code = parsed.errorCode ?? null; } catch { this.code = null; } } } async function request( method: "GET" | "POST", path: string, token: string, body?: unknown, ): Promise { const url = `${BASE_URL}${path}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); let res: Response; try { res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", "User-Agent": "RouteCommerce-WaterLog/1.0", }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, // Disable Next.js fetch caching for API calls — we always want // fresh data from Smartsheet. cache: "no-store", }); } catch (err) { if ((err as { name?: string }).name === "AbortError") { throw new SmartsheetApiError( 408, `Smartsheet request timed out after ${DEFAULT_TIMEOUT_MS}ms`, ); } throw new SmartsheetApiError( 0, `Network error contacting Smartsheet: ${(err as Error).message ?? err}`, ); } finally { clearTimeout(timer); } if (!res.ok) { const errBody = await res.text(); throw new SmartsheetApiError(res.status, errBody); } // Some 204s return no body; guard. const text = await res.text(); if (!text) return undefined as unknown as T; return JSON.parse(text) as T; } /** * Extract the numeric sheet ID from either: * - A bare ID: "123456789012345" * - A URL: "https://app.smartsheet.com/sheets/abc123?..." * * 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 — short-circuit. if (/^\d+$/.test(trimmed)) return trimmed; // 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); const sheetsIdx = segments.findIndex((s) => s === "sheets"); if (sheetsIdx >= 0 && segments[sheetsIdx + 1]) { candidate = segments[sheetsIdx + 1]; } else if (segments.length > 0) { candidate = segments[segments.length - 1] ?? trimmed; } } catch { // Not a URL — leave `candidate` as the original input. } // 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; } /** * Fetch sheet metadata (columns, name) — used by the "Test Connection" * button to populate the column-mapping dropdowns. * * Calls `GET /2.0/sheets/{sheetId}?pageSize=1` — we only need the * header row (column definitions), not the row data, so a 1-row * response keeps the payload tiny. */ export async function getSheetMeta( sheetId: string, token: string, ): Promise { if (!sheetId) throw new Error("sheetId is required"); if (!token) throw new Error("token is required"); type ApiResponse = { // 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; title: string; type: string; index: number; }>; }; const data = await request( "GET", `/2.0/sheets/${encodeURIComponent(sheetId)}?pageSize=1`, token, ); // 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 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.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", index: c.index ?? 0, })), }; } /** * Add a row to a sheet. `cells` is mapped to Smartsheet's * `[{columnId, value, strict?}]` shape. Boolean values are coerced * to "TRUE"/"FALSE" (Smartsheet wants strings for PICKLIST columns). * * Returns the new row's `id` and `rowNumber`. */ export async function addRow( sheetId: string, token: string, cells: SmartsheetCell[], ): Promise<{ rowId: string; rowNumber: number }> { if (!sheetId) throw new Error("sheetId is required"); if (!token) throw new Error("token is required"); if (cells.length === 0) throw new Error("addRow: cells must not be empty"); const toCellValue = ( v: string | number | boolean | null, ): string | boolean | number => { if (v === null || v === undefined) return ""; if (typeof v === "boolean") return v; return v; }; // NOTE: POST /2.0/sheets/{id}/rows response shape varies: // - One row in request → result: { id, rowNumber, ... } (single object) // - Multiple rows → result: [{ id, rowNumber, ... }, ...] (array) // We always post exactly one row, but handle both defensively. Different // from the flat GET /2.0/sheets/{id} response. // See https://smartsheet.redoc.ly/#operation/addRows type ApiResponse = { result: | { id: number; rowNumber: number; [k: string]: unknown } | Array<{ id: number; rowNumber: number; [k: string]: unknown }>; resultCode?: number; message?: string; }; const data = await request( "POST", `/2.0/sheets/${encodeURIComponent(sheetId)}/rows`, token, // Body MUST be a top-level array (or single Row object), NOT wrapped // in { rows: [...] }. When wrapped, Smartsheet returns HTTP 200 + // resultCode 0 + "SUCCESS" but silently drops every cell value — // creates empty rows. (Verified by direct probe: POSTing with // { rows: [...] } returns 200 with no values persisted; POSTing with // [...] returns 200 with values persisted.) This was the root cause // of "rows created but data not inserted" — see the Smartsheet docs // cURL example for POST /sheets/{id}/rows. [ { cells: cells.map((c) => ({ columnId: Number(c.columnId), value: toCellValue(c.value), })), }, ], ); const result = data?.result; const row = Array.isArray(result) ? result[0] : result; 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(row.id), rowNumber: row.rowNumber, }; } /** * Find an existing row by a column value. Used for dedup — if a row * already exists for the given `entry_id`, skip the insert and * record the existing row ID in the queue. * * Implementation: scans the sheet's `columnId` column for `value`. * This is O(n) on sheet size; acceptable for typical water-log sheets * (< 10k rows/yr) but would need a search endpoint for huge sheets. * Smartsheet does not expose a server-side "find by column" endpoint. */ export async function findRowByColumn( sheetId: string, token: string, columnId: string, value: string, ): Promise<{ rowId: string; rowNumber: number } | null> { if (!sheetId || !token || !columnId) return null; // We use `pageSize=1` is useless for search; fetch the whole sheet. // For sheets up to ~10k rows this is fine. For larger, admin should // switch to a sheet-summary API or a backend search. type ApiResponse = { rows: Array<{ id: number; rowNumber: number; cells: Array<{ columnId: number; value?: string | number | boolean | null }>; }>; }; let allRows: ApiResponse["rows"] = []; let page = 1; const PAGE_SIZE = 500; // Bound the search to 5 pages (2,500 rows) to avoid runaway fetches. const MAX_PAGES = 5; while (page <= MAX_PAGES) { const data = await request( "GET", `/2.0/sheets/${encodeURIComponent(sheetId)}?pageSize=${PAGE_SIZE}&page=${page}`, token, ); if (data.rows.length === 0) break; allRows = allRows.concat(data.rows); if (data.rows.length < PAGE_SIZE) break; page += 1; } for (const row of allRows) { const cell = row.cells.find((c) => String(c.columnId) === String(columnId)); if (cell && String(cell.value ?? "") === String(value)) { return { rowId: String(row.id), rowNumber: row.rowNumber }; } } return null; }