feat(water-log): Smartsheet sync integration
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
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).
This commit is contained in:
@@ -625,3 +625,90 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
||||
- `origin/main` (GitHub) — for backup visibility
|
||||
- 36 commits ahead of `origin/main` as of this entry
|
||||
|
||||
|
||||
## 2026-07: Smartsheet sync added to Water Log module
|
||||
|
||||
Per-brand Smartsheet integration that pushes new `water_log_entries`
|
||||
rows to a configured sheet. Config UI lives in
|
||||
`/admin/water-log/settings` as a self-contained card.
|
||||
|
||||
**Key files added:**
|
||||
- `db/migrations/0093_water_smartsheet_sync.sql` — 3 tables:
|
||||
`water_smartsheet_config` (one row per brand; encrypted token +
|
||||
sheet ID + column mapping + frequency + enable flag + last-sync
|
||||
metadata), `water_smartsheet_sync_queue` (one row per entry
|
||||
awaiting sync; status / attempts / backoff), `water_smartsheet_sync_log`
|
||||
(append-only Recent Activity feed for the UI). RLS via the existing
|
||||
`current_brand_id()` / `is_platform_admin()` pattern.
|
||||
- `db/schema/water-log.ts` — appended the three tables + types
|
||||
(`SmartsheetFrequency`, `SmartsheetColumnKey`, `SmartsheetColumnMapping`).
|
||||
Uses a small custom `bytea` Drizzle type since `drizzle-orm/pg-core`
|
||||
doesn't export one in this version.
|
||||
- `src/lib/crypto.ts` — AES-256-GCM helpers (`encryptToken`,
|
||||
`decryptToken`, `maskToken`). Reads `SMARTSHEET_TOKEN_ENC_KEY`.
|
||||
Throws on missing/wrong-size key — no insecure default.
|
||||
- `src/lib/smartsheet.ts` — REST API wrapper (no SDK — `smartsheet`
|
||||
npm has Node 22 compat issues). Exposes `extractSheetId`,
|
||||
`getSheetMeta`, `addRow`, `findRowByColumn`, and a typed
|
||||
`SmartsheetApiError` class.
|
||||
- `src/services/smartsheet-sync.ts` — pure sync engine:
|
||||
`syncEntryToSmartsheet`, `drainSyncQueue`, `runScheduledSync`.
|
||||
Sequential per-row processing to stay well under Smartsheet's
|
||||
300 req/min cap. Exponential backoff capped at 60 min, max 5
|
||||
attempts before the row stays `failed` permanently.
|
||||
- `src/actions/water-log/smartsheet.ts` — `"use server"` actions:
|
||||
`getSmartsheetConfig`, `saveSmartsheetConfig`, `deleteSmartsheetConfig`,
|
||||
`testSmartsheetConnection`, `triggerSyncForEntry`, `listSmartsheetSyncLog`.
|
||||
Token never returned in plaintext — UI gets `maskedToken` only.
|
||||
- `src/components/admin/water-log/SmartsheetIntegrationCard.tsx` —
|
||||
client component using `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/api/water-log/smartsheet-sync/route.ts` — POST cron route.
|
||||
Bearer-auth via `SMARTSHEET_CRON_SECRET` (falls back to `CRON_SECRET`).
|
||||
Optional `{brandId}` body to drain one brand.
|
||||
- `vercel.json` — added `*/15 * * * *` cron entry pointing at the
|
||||
route above. Single entry covers both 15-min and hourly frequencies
|
||||
(route filters by per-brand `sync_frequency`).
|
||||
|
||||
**Hooks into existing code:**
|
||||
- `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.
|
||||
- `src/app/admin/water-log/settings/page.tsx` — mounted the new card
|
||||
as a new "§ 05 — Integrations" section after the existing admin-
|
||||
PIN settings. Card takes `brandId` as a prop (CLAUDE.md "Brand ID
|
||||
Threading").
|
||||
|
||||
**Env vars (`.env.example`):**
|
||||
- `SMARTSHEET_TOKEN_ENC_KEY` — REQUIRED, 32 random bytes base64.
|
||||
Generate: `openssl rand -base64 32`.
|
||||
- `SMARTSHEET_CRON_SECRET` — optional bearer for the cron route.
|
||||
- `SMARTSHEET_SYNC_TIMEOUT_MS` — optional, default 8000.
|
||||
|
||||
**Migration push (Vercel deploy + manual):**
|
||||
```bash
|
||||
npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql
|
||||
```
|
||||
Then provision the encryption key in Vercel dashboard:
|
||||
`SMARTSHEET_TOKEN_ENC_KEY` (paste the `openssl rand -base64 32` output).
|
||||
|
||||
**Rollback story:**
|
||||
1. Remove the cron entry from `vercel.json`.
|
||||
2. SQL rollback: `DROP TABLE water_smartsheet_sync_log;`
|
||||
`DROP TABLE water_smartsheet_sync_queue;` `DROP TABLE water_smartsheet_config;`
|
||||
3. Revert the changes to `field.ts` and `settings/page.tsx`.
|
||||
4. `git revert` the merge commit.
|
||||
|
||||
**Known sharp edges:**
|
||||
- No key rotation support — changing `SMARTSHEET_TOKEN_ENC_KEY` makes
|
||||
existing tokens unreadable; admins must re-enter. Document if/when
|
||||
Phase 2 adds a `key_version` column.
|
||||
- Share-URL Smartsheet sheets often have opaque slugs (not numeric);
|
||||
if "Test Connection" 404s with a slug URL, ask the brand admin for
|
||||
the numeric sheet ID from `File → Properties` in Smartsheet.
|
||||
- `findRowByColumn` is O(n) over up to 2,500 rows; fine for typical
|
||||
water-log sheets but switches to search-API dependency if any
|
||||
brand ever exceeds ~10k entries/year.
|
||||
|
||||
Reference in New Issue
Block a user