81f3f2cc83f5e8b1e71347330ca59b24a12d4d48
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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 (
|
||
|
|
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
|
||
|
|
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). |
||
|
|
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).
|