Commit Graph

5 Commits

Author SHA1 Message Date
Nora 81f3f2cc83 fix(smartsheet): send addRow body as top-level array, not { rows: [...] }
Deploy to route.crispygoat.com / deploy (push) Successful in 3m57s
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
Nora 5aab432e5a fix(smartsheet): handle POST /rows response as either object or array
Deploy to route.crispygoat.com / deploy (push) Successful in 4m5s
Smartsheet's POST /sheets/{id}/rows response shape varies by request size:
  - Single-row request → result: { id, rowNumber, ... }   (object)
  - Multi-row request  → result: [{ id, rowNumber, ... }] (array)

We always post exactly one row, so the actual response is the object
form. The previous fix (b061cdd) assumed the array form, so
data.result[0] was undefined for object responses, and addRow threw
a 502 'returned no row' AFTER Smartsheet had successfully created
the row. The queue then recorded a failed sync attempt even though
the data was sitting in the sheet.

User saw this in Recent Activity: 10 sequential retries with
'Smartsheet 502: returned no row' errors but incrementing rowNumber
in the response body (21 → 30), proving the rows were being created
on Smartsheet's side and our parser was the only thing failing.

Fix: check Array.isArray(result) and use result[0] for the array form,
or result directly for the object form. Belt-and-suspenders for both
shapes since Smartsheet's docs are ambiguous about the single-row case.

Side note: the failed attempts did create rows on Smartsheet. Once
the fix deploys, retries will hit findRowByColumn first, which will
locate those existing rows by Entry ID and skip the duplicate insert.
Net result: 25 unique rows in the sheet, queue marked synced.
2026-07-03 16:53:49 -06:00
Nora b061cdd289 fix(smartsheet): read flat response shape for GET sheet, array result for POST row
Deploy to route.crispygoat.com / deploy (push) Successful in 4m11s
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.
2026-07-03 16:31:22 -06:00
Nora 71e1792ee3 fix(smartsheet): detect share-link slugs and guard malformed API responses
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).
2026-07-03 16:18:47 -06:00
Nora fc70344dab feat(water-log): Smartsheet sync integration
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.

Database (0093_water_smartsheet_sync.sql):
- water_smartsheet_config: one row per brand; AES-256-GCM encrypted API
  token (BYTEA), sheet id, column mapping JSONB, sync_frequency
  (realtime | every_15_minutes | hourly), enable flag, last-sync
  metadata. RLS via existing current_brand_id() pattern.
- water_smartsheet_sync_queue: one row per entry awaiting sync;
  tracks status / attempts / exponential backoff. UNIQUE(brand_id,
  entry_id) for idempotent enqueue.
- water_smartsheet_sync_log: append-only audit of every sync attempt
  (success or failure); backs the Recent Activity panel in the UI.

Server:
- src/lib/crypto.ts: AES-256-GCM encrypt/decrypt + token mask helper.
  Reads SMARTSHEET_TOKEN_ENC_KEY. Throws on missing/wrong-size key.
- src/lib/smartsheet.ts: typed REST wrapper (no SDK; the official
  smartsheet npm has Node 22 compat issues). getSheetMeta, addRow,
  findRowByColumn + typed SmartsheetApiError.
- src/services/smartsheet-sync.ts: syncEntryToSmartsheet (one entry),
  drainSyncQueue (batch with backoff), runScheduledSync (cron entry).
  Sequential processing stays well under Smartsheet's 300 req/min.
  Max 5 attempts, then row stays 'failed' permanently.
- src/actions/water-log/smartsheet.ts: 6 server actions. Token never
  returned in plaintext — UI gets maskedToken only. Permission check
  matches the rest of the water-log module (can_manage_water_log).
- src/app/api/water-log/smartsheet-sync/route.ts: POST cron route,
  bearer auth via SMARTSHEET_CRON_SECRET (falls back to CRON_SECRET).
  Optional {brandId} body for manual retry.
- vercel.json: added */15 * * * * cron entry. Single entry covers
  both 15-min and hourly frequencies (route filters per-brand).

UI:
- src/components/admin/water-log/SmartsheetIntegrationCard.tsx:
  self-contained client component with useReducer. Sections: enable
  toggle, sheet URL/ID + API token + Test Connection, sync frequency
  radio group, column mapping dropdowns (populated after Test),
  Recent Activity panel.
- src/app/admin/water-log/settings/page.tsx: mounted the card as new
  '§ 05 — Integrations' section after the existing admin-PIN settings.
  Card takes brandId as a prop (CLAUDE.md 'Brand ID Threading').

Hooks:
- src/actions/water-log/field.ts::submitWaterEntry: calls
  triggerSyncForEntry(brandId, entryId) in a fire-and-forget void
  after the entry insert. Sync failures NEVER bubble to the field
  worker.

Env vars (.env.example):
- SMARTSHEET_TOKEN_ENC_KEY (REQUIRED, 32 random bytes base64)
- SMARTSHEET_CRON_SECRET (optional bearer)
- SMARTSHEET_SYNC_TIMEOUT_MS (optional, default 8000)

Deploy:
1. openssl rand -base64 32 → SMARTSHEET_TOKEN_ENC_KEY in .env.local
   AND Vercel dashboard
2. npm run migrate:one 93  (pushes 0093_water_smartsheet_sync.sql)
3. Push triggers .gitea/workflows/deploy.yml

Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
2026-07-03 14:43:23 -06:00