Files
route-commerce/src/lib/smartsheet.ts
T
Nora 81f3f2cc83
Deploy to route.crispygoat.com / deploy (push) Successful in 3m57s
fix(smartsheet): send addRow body as top-level array, not { rows: [...] }
Smartsheet's POST /sheets/{id}/rows expects the body to be a top-level
JSON array (or a single Row object), NOT wrapped in { rows: [...] }.

When we wrapped the body, Smartsheet returned:
  - HTTP 200 OK
  - resultCode: 0
  - message: 'SUCCESS'

…and silently dropped every cell value, creating an empty row. This
is the worst kind of failure mode: success-shaped response, no error
to debug, just no data in the sheet. (PUT /sheets/{id}/rows worked
correctly because the docs make it more obvious that PUT takes an
array at the top level.)

Root cause confirmed by direct API probe:
  - Body: { rows: [{ cells: [...] }] }  → 200, no values persisted
  - Body: [{ cells: [...] }]            → 200, values persisted

Fix: send the array directly as the request body.

After deploy, retries against the 25 queued entries will:
  1. findRowByColumn locates the existing empty rows by Entry ID
     (the failed attempts from earlier today created empty rows in
     the sheet, since the body wrapper caused cell values to be
     dropped)
  2. No duplicate insert; the existing empty row is reused
  3. Queue marked as synced with smartsheetRowId

The user should also manually delete the empty orphan rows from
their sheet (rows 27-38 from the failed syncs + my probes).

Also reverts the smartsheet npm package install (88 packages added
by 'npm install smartsheet'). The REST fix is one line; the SDK's
deps weren't worth keeping.
2026-07-03 17:07:40 -06:00

386 lines
13 KiB
TypeScript

/**
* 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 <token>` 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<T>(
method: "GET" | "POST",
path: string,
token: string,
body?: unknown,
): Promise<T> {
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/<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 — 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<SmartsheetSheetMeta> {
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<ApiResponse>(
"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<ApiResponse>(
"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<ApiResponse>(
"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;
}