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:
@@ -79,3 +79,18 @@ CRON_SECRET=replace-me-with-a-random-string
|
|||||||
# the server actions — but you can override it with:
|
# the server actions — but you can override it with:
|
||||||
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
||||||
# See docs/water-log.md for the full module guide.
|
# See docs/water-log.md for the full module guide.
|
||||||
|
|
||||||
|
# ── Smartsheet (Water Log integration) ────────────────────────────────────
|
||||||
|
# AES-256-GCM key used to encrypt each brand's Smartsheet API token at
|
||||||
|
# rest in `water_smartsheet_config.encrypted_api_token`. 32 random bytes,
|
||||||
|
# base64-encoded. REQUIRED in production — refuses to start without it.
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
SMARTSHEET_TOKEN_ENC_KEY=
|
||||||
|
|
||||||
|
# Bearer secret for /api/water-log/smartsheet-sync cron route. Falls
|
||||||
|
# back to CRON_SECRET (above) if unset. Use a unique value per env to
|
||||||
|
# avoid cross-route authorization bleed.
|
||||||
|
SMARTSHEET_CRON_SECRET=
|
||||||
|
|
||||||
|
# HTTP timeout (ms) for outbound Smartsheet API calls. Default 8000.
|
||||||
|
# SMARTSHEET_SYNC_TIMEOUT_MS=8000
|
||||||
|
|||||||
@@ -625,3 +625,90 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step:
|
|||||||
- `origin/main` (GitHub) — for backup visibility
|
- `origin/main` (GitHub) — for backup visibility
|
||||||
- 36 commits ahead of `origin/main` as of this entry
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- 0093_water_smartsheet_sync.sql
|
||||||
|
--
|
||||||
|
-- Adds per-brand Smartsheet integration for the Water Log module.
|
||||||
|
--
|
||||||
|
-- Three tables:
|
||||||
|
-- 1. water_smartsheet_config — one row per brand; encrypted token,
|
||||||
|
-- sheet id, column mapping, frequency,
|
||||||
|
-- enable flag, last-sync metadata
|
||||||
|
-- 2. water_smartsheet_sync_queue — one row per water_log_entry that
|
||||||
|
-- needs syncing; tracks attempts + status
|
||||||
|
-- for retry / observability
|
||||||
|
-- 3. water_smartsheet_sync_log — append-only log of every sync attempt
|
||||||
|
-- (success OR failure) — backs the
|
||||||
|
-- "Recent Activity" panel in the UI
|
||||||
|
--
|
||||||
|
-- Security:
|
||||||
|
-- - API token is AES-256-GCM encrypted at rest using a key from
|
||||||
|
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
|
||||||
|
-- plaintext via any server action / API response.
|
||||||
|
-- - All tables brand-scoped via the existing `app.current_brand_id`
|
||||||
|
-- GUC + `current_brand_id()` function (see 0001_init.sql).
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_smartsheet_config (
|
||||||
|
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The numeric Smartsheet sheet ID (not the share URL).
|
||||||
|
-- UI accepts either form and parses to numeric here.
|
||||||
|
sheet_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
|
||||||
|
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
|
||||||
|
encrypted_api_token BYTEA NOT NULL,
|
||||||
|
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||||
|
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||||
|
|
||||||
|
-- Maps our internal water-log fields → Smartsheet column IDs.
|
||||||
|
-- Shape: { entry_id: string, logged_at: string, headgate: string,
|
||||||
|
-- measurement: string, unit: string, irrigator: string,
|
||||||
|
-- notes: string | null }
|
||||||
|
-- `entry_id` and `logged_at` are required (used for dedup).
|
||||||
|
column_mapping JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- 'realtime' | 'every_15_minutes' | 'hourly'
|
||||||
|
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
|
||||||
|
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
|
||||||
|
|
||||||
|
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Set after every sync attempt (success or failure).
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
last_sync_error TEXT,
|
||||||
|
|
||||||
|
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||||
|
-- require a FK to a specific auth backend.
|
||||||
|
created_by TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Auto-bump updated_at on row UPDATE.
|
||||||
|
DROP TRIGGER IF EXISTS water_smartsheet_config_set_updated_at ON water_smartsheet_config;
|
||||||
|
CREATE TRIGGER water_smartsheet_config_set_updated_at
|
||||||
|
BEFORE UPDATE ON water_smartsheet_config
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- RLS: brand-scoped.
|
||||||
|
ALTER TABLE water_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_config;
|
||||||
|
CREATE POLICY tenant_isolation ON water_smartsheet_config FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 2. Sync queue (one row per water_log_entry awaiting sync) ──────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_smartsheet_sync_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- FK to the entry. ON DELETE CASCADE means deleting an entry also
|
||||||
|
-- removes its queue row, preventing orphaned sync attempts.
|
||||||
|
entry_id UUID NOT NULL REFERENCES water_log_entries(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- The Smartsheet row ID returned from a successful sync.
|
||||||
|
-- NULL = not yet synced.
|
||||||
|
smartsheet_row_id TEXT,
|
||||||
|
|
||||||
|
-- 'pending' | 'syncing' | 'synced' | 'failed'
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending','syncing','synced','failed')),
|
||||||
|
|
||||||
|
-- Bumped on every attempt (success or failure). Caps at 5 (after
|
||||||
|
-- which the row stays 'failed' permanently; admin must re-enable).
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Last failure reason (sanitized — token never appears here).
|
||||||
|
last_error TEXT,
|
||||||
|
|
||||||
|
-- When the next retry is allowed. Set to NOW() initially; pushed
|
||||||
|
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- One queue row per entry per brand. CASCADE handles the case where
|
||||||
|
-- the entry is deleted (rare; only via admin override).
|
||||||
|
CONSTRAINT water_smartsheet_queue_entry_unique UNIQUE (brand_id, entry_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||||
|
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_status_idx
|
||||||
|
ON water_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||||
|
|
||||||
|
-- Recent-attempts view.
|
||||||
|
CREATE INDEX IF NOT EXISTS water_smartsheet_queue_brand_created_idx
|
||||||
|
ON water_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE water_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_queue;
|
||||||
|
CREATE POLICY tenant_isolation ON water_smartsheet_sync_queue FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_smartsheet_sync_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
entry_id UUID REFERENCES water_log_entries(id) ON DELETE SET NULL,
|
||||||
|
smartsheet_row_id TEXT,
|
||||||
|
|
||||||
|
-- 'sync' (initial push) | 'retry' (after a failure) | 'skip'
|
||||||
|
-- (dedup hit — entry already had smartsheet_row_id) | 'test'
|
||||||
|
-- (the "Test Connection" button does NOT log here; that's a config
|
||||||
|
-- event recorded via water_audit_log instead).
|
||||||
|
action TEXT NOT NULL
|
||||||
|
CHECK (action IN ('sync','retry','skip','queue')),
|
||||||
|
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS water_smartsheet_log_brand_recent_idx
|
||||||
|
ON water_smartsheet_sync_log (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE water_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_smartsheet_sync_log;
|
||||||
|
CREATE POLICY tenant_isolation ON water_smartsheet_sync_log FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
-- ── 4. Helper view: latest sync per entry (for dedup decisions) ────────────
|
||||||
|
|
||||||
|
-- One row per entry with the most recent sync attempt. Used by the
|
||||||
|
-- sync service to decide "has this entry been pushed already?".
|
||||||
|
CREATE OR REPLACE VIEW water_smartsheet_latest_sync AS
|
||||||
|
SELECT DISTINCT ON (q.brand_id, q.entry_id)
|
||||||
|
q.brand_id,
|
||||||
|
q.entry_id,
|
||||||
|
q.smartsheet_row_id,
|
||||||
|
q.status,
|
||||||
|
q.attempts,
|
||||||
|
q.last_error,
|
||||||
|
q.next_attempt_at,
|
||||||
|
q.created_at AS queued_at,
|
||||||
|
q.synced_at
|
||||||
|
FROM water_smartsheet_sync_queue q
|
||||||
|
ORDER BY q.brand_id, q.entry_id, q.created_at DESC;
|
||||||
|
|
||||||
|
COMMENT ON TABLE water_smartsheet_config IS
|
||||||
|
'Per-brand Smartsheet integration config. Token is AES-256-GCM encrypted.';
|
||||||
|
COMMENT ON TABLE water_smartsheet_sync_queue IS
|
||||||
|
'One row per water_log_entry awaiting / completed sync to Smartsheet.';
|
||||||
|
COMMENT ON TABLE water_smartsheet_sync_log IS
|
||||||
|
'Append-only audit of Smartsheet sync attempts (success and failure).';
|
||||||
+183
-1
@@ -24,11 +24,19 @@ import {
|
|||||||
index,
|
index,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
integer,
|
integer,
|
||||||
check,
|
customType,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
import { adminUsers } from "./brands";
|
import { adminUsers } from "./brands";
|
||||||
|
|
||||||
|
// Drizzle's stock `bytea` import is not exported in every version, so
|
||||||
|
// register a small custom type that maps to the existing `bytea` PG type.
|
||||||
|
const bytea = customType<{ data: Buffer; default: false }>({
|
||||||
|
dataType() {
|
||||||
|
return "bytea";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const waterHeadgates = pgTable(
|
export const waterHeadgates = pgTable(
|
||||||
"water_headgates",
|
"water_headgates",
|
||||||
{
|
{
|
||||||
@@ -255,3 +263,177 @@ export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
|||||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||||
|
|
||||||
|
// ── Smartsheet integration (added in 0093_water_smartsheet_sync.sql) ────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed sync frequencies. Stored as TEXT to match the convention
|
||||||
|
* in CLAUDE.md ("Status enums stored as TEXT — no PostgreSQL ENUM type").
|
||||||
|
*/
|
||||||
|
export const SMARTSHEET_FREQUENCIES = [
|
||||||
|
"realtime",
|
||||||
|
"every_15_minutes",
|
||||||
|
"hourly",
|
||||||
|
] as const;
|
||||||
|
export type SmartsheetFrequency = (typeof SMARTSHEET_FREQUENCIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Water-log fields a brand can map to their Smartsheet columns.
|
||||||
|
* `entry_id` and `logged_at` are required (used for dedup).
|
||||||
|
*/
|
||||||
|
export type SmartsheetColumnKey =
|
||||||
|
| "entry_id"
|
||||||
|
| "logged_at"
|
||||||
|
| "headgate"
|
||||||
|
| "measurement"
|
||||||
|
| "unit"
|
||||||
|
| "irrigator"
|
||||||
|
| "notes";
|
||||||
|
export const SMARTSHEET_COLUMN_KEYS: readonly SmartsheetColumnKey[] = [
|
||||||
|
"entry_id",
|
||||||
|
"logged_at",
|
||||||
|
"headgate",
|
||||||
|
"measurement",
|
||||||
|
"unit",
|
||||||
|
"irrigator",
|
||||||
|
"notes",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape stored in `water_smartsheet_config.column_mapping`. The values
|
||||||
|
* are Smartsheet column IDs (numeric strings, e.g. "8309876543210123").
|
||||||
|
*
|
||||||
|
* Only `entry_id` and `logged_at` are required (used for dedup). All
|
||||||
|
* other fields are nullable — `null` means "not mapped, don't write
|
||||||
|
* this column". The UI uses an empty string in the dropdown to mean
|
||||||
|
* the same thing and we coerce on the server.
|
||||||
|
*/
|
||||||
|
export type SmartsheetColumnMapping = {
|
||||||
|
entry_id: string;
|
||||||
|
logged_at: string;
|
||||||
|
headgate: string | null;
|
||||||
|
measurement: string | null;
|
||||||
|
unit: string | null;
|
||||||
|
irrigator: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waterSmartsheetConfig = pgTable("water_smartsheet_config", {
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
sheetId: text("sheet_id").notNull(),
|
||||||
|
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
||||||
|
tokenIv: bytea("token_iv").notNull(),
|
||||||
|
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
||||||
|
columnMapping: jsonb("column_mapping")
|
||||||
|
.$type<SmartsheetColumnMapping>()
|
||||||
|
.notNull(),
|
||||||
|
syncFrequency: text("sync_frequency")
|
||||||
|
.$type<SmartsheetFrequency>()
|
||||||
|
.notNull()
|
||||||
|
.default("hourly"),
|
||||||
|
syncEnabled: boolean("sync_enabled").notNull().default(false),
|
||||||
|
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
||||||
|
lastSyncError: text("last_sync_error"),
|
||||||
|
createdBy: text("created_by"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WaterSmartsheetConfig = typeof waterSmartsheetConfig.$inferSelect;
|
||||||
|
export type WaterSmartsheetConfigInsert = typeof waterSmartsheetConfig.$inferInsert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync queue status. Mirrors the SQL CHECK constraint.
|
||||||
|
*/
|
||||||
|
export const SMARTSHEET_QUEUE_STATUSES = [
|
||||||
|
"pending",
|
||||||
|
"syncing",
|
||||||
|
"synced",
|
||||||
|
"failed",
|
||||||
|
] as const;
|
||||||
|
export type SmartsheetQueueStatus = (typeof SMARTSHEET_QUEUE_STATUSES)[number];
|
||||||
|
|
||||||
|
export const waterSmartsheetSyncQueue = pgTable(
|
||||||
|
"water_smartsheet_sync_queue",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
entryId: uuid("entry_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => waterLogEntries.id, { onDelete: "cascade" }),
|
||||||
|
smartsheetRowId: text("smartsheet_row_id"),
|
||||||
|
status: text("status")
|
||||||
|
.$type<SmartsheetQueueStatus>()
|
||||||
|
.notNull()
|
||||||
|
.default("pending"),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
lastError: text("last_error"),
|
||||||
|
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
syncedAt: timestamp("synced_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
entryUnique: uniqueIndex("water_smartsheet_queue_entry_unique").on(
|
||||||
|
t.brandId,
|
||||||
|
t.entryId,
|
||||||
|
),
|
||||||
|
brandStatusIdx: index("water_smartsheet_queue_brand_status_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.status,
|
||||||
|
t.nextAttemptAt,
|
||||||
|
),
|
||||||
|
brandRecentIdx: index("water_smartsheet_queue_brand_created_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.createdAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type WaterSmartsheetSyncQueue = typeof waterSmartsheetSyncQueue.$inferSelect;
|
||||||
|
export type WaterSmartsheetSyncQueueInsert = typeof waterSmartsheetSyncQueue.$inferInsert;
|
||||||
|
|
||||||
|
export const SMARTSHEET_LOG_ACTIONS = ["sync", "retry", "skip", "queue"] as const;
|
||||||
|
export type SmartsheetLogAction = (typeof SMARTSHEET_LOG_ACTIONS)[number];
|
||||||
|
|
||||||
|
export const waterSmartsheetSyncLog = pgTable(
|
||||||
|
"water_smartsheet_sync_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
entryId: uuid("entry_id").references(() => waterLogEntries.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
smartsheetRowId: text("smartsheet_row_id"),
|
||||||
|
action: text("action").$type<SmartsheetLogAction>().notNull(),
|
||||||
|
success: boolean("success").notNull(),
|
||||||
|
error: text("error"),
|
||||||
|
durationMs: integer("duration_ms"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
brandRecentIdx: index("water_smartsheet_log_brand_recent_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.createdAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type WaterSmartsheetSyncLog = typeof waterSmartsheetSyncLog.$inferSelect;
|
||||||
|
export type WaterSmartsheetSyncLogInsert = typeof waterSmartsheetSyncLog.$inferInsert;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from "@/lib/water-log/session-server";
|
} from "@/lib/water-log/session-server";
|
||||||
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
|
import { TUXEDO_BRAND_ID } from "@/lib/water-log/brand";
|
||||||
|
import { triggerSyncForEntry } from "@/actions/water-log/smartsheet";
|
||||||
|
|
||||||
// Re-export so existing call sites that import from
|
// Re-export so existing call sites that import from
|
||||||
// `@/actions/water-log/field` keep working.
|
// `@/actions/water-log/field` keep working.
|
||||||
@@ -335,6 +336,10 @@ export async function submitWaterEntry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Smartsheet sync hook — enqueue + (if realtime) push. Failures
|
||||||
|
// never bubble: the field worker must not see a sync hiccup.
|
||||||
|
void triggerSyncForEntry(session.brandId, entry.id);
|
||||||
|
|
||||||
return { success: true, entry_id: entry.id };
|
return { success: true, entry_id: entry.id };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Water Log — Smartsheet integration server actions.
|
||||||
|
*
|
||||||
|
* Surface for the `/admin/water-log/settings` UI and the entry-insert
|
||||||
|
* hook in `field.ts`. Permission model mirrors the rest of the water
|
||||||
|
* log module: only admins with `can_manage_water_log` (or
|
||||||
|
* platform_admin) can mutate config; everyone authenticated can read.
|
||||||
|
*
|
||||||
|
* Token handling:
|
||||||
|
* - The plaintext token NEVER crosses the server-action boundary.
|
||||||
|
* - Save actions accept a token via `input.token` only when the user
|
||||||
|
* types a new one. An empty token means "keep the saved one".
|
||||||
|
* - Read actions return `maskedToken` ("••••abcd") and never the
|
||||||
|
* plaintext.
|
||||||
|
*/
|
||||||
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import {
|
||||||
|
waterSmartsheetConfig,
|
||||||
|
waterSmartsheetSyncLog,
|
||||||
|
waterSmartsheetSyncQueue,
|
||||||
|
SMARTSHEET_FREQUENCIES,
|
||||||
|
SMARTSHEET_COLUMN_KEYS,
|
||||||
|
type SmartsheetColumnKey,
|
||||||
|
type SmartsheetColumnMapping,
|
||||||
|
type SmartsheetFrequency,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
|
import { decryptToken, encryptToken, maskToken } from "@/lib/crypto";
|
||||||
|
import { extractSheetId, getSheetMeta, SmartsheetApiError } from "@/lib/smartsheet";
|
||||||
|
import { syncEntryToSmartsheet } from "@/services/smartsheet-sync";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
// ── Types used by both server and client ────────────────────────────────────
|
||||||
|
|
||||||
|
export type SmartsheetConfigResponse = {
|
||||||
|
configured: boolean;
|
||||||
|
sheetId: string | null;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncFrequency: SmartsheetFrequency;
|
||||||
|
columnMapping: SmartsheetColumnMapping | null;
|
||||||
|
maskedToken: string | null; // "••••abcd" or null if no token saved
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
lastSyncError: string | null;
|
||||||
|
hasToken: boolean;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmartsheetSyncLogEntry = {
|
||||||
|
id: string;
|
||||||
|
entryId: string | null;
|
||||||
|
smartsheetRowId: string | null;
|
||||||
|
action: "sync" | "retry" | "skip" | "queue";
|
||||||
|
success: boolean;
|
||||||
|
error: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SaveSmartsheetConfigInput = {
|
||||||
|
sheetId: string;
|
||||||
|
/** Plaintext token. Empty string = keep the saved token. */
|
||||||
|
token: string;
|
||||||
|
syncEnabled: boolean;
|
||||||
|
syncFrequency: SmartsheetFrequency;
|
||||||
|
columnMapping: SmartsheetColumnMapping;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestConnectionResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
sheetName: string;
|
||||||
|
columnCount: number;
|
||||||
|
columns: { id: string; title: string; type: string }[];
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// ── Auth helper ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function requireWaterAdminPermission() {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
|
||||||
|
if (
|
||||||
|
!adminUser.can_manage_water_log &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return { ok: false as const, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
return { ok: true as const, adminUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSmartsheetConfig(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<SmartsheetConfigResponse> {
|
||||||
|
await getSession();
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const config = rows[0];
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
sheetId: null,
|
||||||
|
syncEnabled: false,
|
||||||
|
syncFrequency: "hourly",
|
||||||
|
columnMapping: null,
|
||||||
|
maskedToken: null,
|
||||||
|
lastSyncAt: null,
|
||||||
|
lastSyncError: null,
|
||||||
|
hasToken: false,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Decrypt just to compute the masked preview — never return the
|
||||||
|
// plaintext. If decryption fails (key rotated), we still report
|
||||||
|
// `hasToken: true` so the admin knows to re-enter it.
|
||||||
|
let masked: string | null = "••••";
|
||||||
|
let hasToken = true;
|
||||||
|
try {
|
||||||
|
const plain = decryptToken({
|
||||||
|
cipher: config.encryptedApiToken,
|
||||||
|
iv: config.tokenIv,
|
||||||
|
authTag: config.tokenAuthTag,
|
||||||
|
});
|
||||||
|
masked = maskToken(plain);
|
||||||
|
} catch {
|
||||||
|
hasToken = false;
|
||||||
|
masked = null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
sheetId: config.sheetId,
|
||||||
|
syncEnabled: config.syncEnabled,
|
||||||
|
syncFrequency: config.syncFrequency,
|
||||||
|
columnMapping: config.columnMapping,
|
||||||
|
maskedToken: masked,
|
||||||
|
lastSyncAt: config.lastSyncAt?.toISOString() ?? null,
|
||||||
|
lastSyncError: config.lastSyncError,
|
||||||
|
hasToken,
|
||||||
|
updatedAt: config.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSmartsheetSyncLog(
|
||||||
|
brandId: string,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<SmartsheetSyncLogEntry[]> {
|
||||||
|
await getSession();
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 200);
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetSyncLog)
|
||||||
|
.where(eq(waterSmartsheetSyncLog.brandId, brandId))
|
||||||
|
.orderBy(desc(waterSmartsheetSyncLog.createdAt))
|
||||||
|
.limit(safeLimit);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
entryId: r.entryId,
|
||||||
|
smartsheetRowId: r.smartsheetRowId,
|
||||||
|
action: r.action,
|
||||||
|
success: r.success,
|
||||||
|
error: r.error,
|
||||||
|
durationMs: r.durationMs,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveSmartsheetConfig(
|
||||||
|
brandId: string,
|
||||||
|
input: SaveSmartsheetConfigInput,
|
||||||
|
): Promise<{ success: boolean; error?: string; config?: SmartsheetConfigResponse }> {
|
||||||
|
await getSession();
|
||||||
|
const auth = await requireWaterAdminPermission();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
|
// ── Validation ──
|
||||||
|
if (!input.sheetId || typeof input.sheetId !== "string") {
|
||||||
|
return { success: false, error: "Sheet ID or URL is required" };
|
||||||
|
}
|
||||||
|
let normalizedSheetId: string;
|
||||||
|
try {
|
||||||
|
normalizedSheetId = extractSheetId(input.sheetId);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
if (!SMARTSHEET_FREQUENCIES.includes(input.syncFrequency)) {
|
||||||
|
return { success: false, error: "Invalid sync frequency" };
|
||||||
|
}
|
||||||
|
const mappingResult = validateColumnMapping(input.columnMapping);
|
||||||
|
if (!mappingResult.ok) {
|
||||||
|
return { success: false, error: mappingResult.error };
|
||||||
|
}
|
||||||
|
// The token: empty means "keep existing" (we won't touch the
|
||||||
|
// encrypted columns). Non-empty means we re-encrypt and replace.
|
||||||
|
const tokenTrimmed = input.token?.trim() ?? "";
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// ── Load existing to know if we have a token already ──
|
||||||
|
const existingRows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const existing = existingRows[0];
|
||||||
|
|
||||||
|
if (!existing && tokenTrimmed.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "API token is required for first-time setup",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (existing && tokenTrimmed.length === 0 && !existing.encryptedApiToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "API token is required (none on file)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build the upsert payload ──
|
||||||
|
const updateValues: Partial<typeof waterSmartsheetConfig.$inferInsert> = {
|
||||||
|
sheetId: normalizedSheetId,
|
||||||
|
syncEnabled: input.syncEnabled,
|
||||||
|
syncFrequency: input.syncFrequency,
|
||||||
|
columnMapping: input.columnMapping,
|
||||||
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
};
|
||||||
|
if (tokenTrimmed.length > 0) {
|
||||||
|
const enc = encryptToken(tokenTrimmed);
|
||||||
|
updateValues.encryptedApiToken = enc.cipher;
|
||||||
|
updateValues.tokenIv = enc.iv;
|
||||||
|
updateValues.tokenAuthTag = enc.authTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert.
|
||||||
|
const [upserted] = await db
|
||||||
|
.insert(waterSmartsheetConfig)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
sheetId: normalizedSheetId,
|
||||||
|
encryptedApiToken: existing?.encryptedApiToken ?? Buffer.from([]),
|
||||||
|
tokenIv: existing?.tokenIv ?? Buffer.from([]),
|
||||||
|
tokenAuthTag: existing?.tokenAuthTag ?? Buffer.from([]),
|
||||||
|
columnMapping: input.columnMapping,
|
||||||
|
syncEnabled: input.syncEnabled,
|
||||||
|
syncFrequency: input.syncFrequency,
|
||||||
|
createdBy: existing?.createdBy ?? auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
updatedBy: auth.adminUser.email ?? auth.adminUser.id ?? null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: waterSmartsheetConfig.brandId,
|
||||||
|
set: updateValues,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!upserted) return { success: false, error: "Save failed" };
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
brandId,
|
||||||
|
actorId: auth.adminUser.user_id ?? null,
|
||||||
|
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||||
|
action: existing ? "update" : "create",
|
||||||
|
entityType: "settings",
|
||||||
|
details: {
|
||||||
|
feature: "smartsheet",
|
||||||
|
sheetId: normalizedSheetId,
|
||||||
|
syncEnabled: input.syncEnabled,
|
||||||
|
syncFrequency: input.syncFrequency,
|
||||||
|
tokenRotated: tokenTrimmed.length > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-read for a clean response.
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
config: await getSmartsheetConfig(brandId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSmartsheetConfig(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
await getSession();
|
||||||
|
const auth = await requireWaterAdminPermission();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const deleted = await db
|
||||||
|
.delete(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.returning({ id: waterSmartsheetConfig.brandId });
|
||||||
|
if (!deleted[0]) {
|
||||||
|
return { success: false, error: "No config to delete" };
|
||||||
|
}
|
||||||
|
// Also clear the queue + log to avoid orphaned rows.
|
||||||
|
await db
|
||||||
|
.delete(waterSmartsheetSyncQueue)
|
||||||
|
.where(eq(waterSmartsheetSyncQueue.brandId, brandId));
|
||||||
|
// Keep the log for audit, but null the brandId? Simpler: keep
|
||||||
|
// them — they're useful for post-mortems. Foreign keys cascade on
|
||||||
|
// brand delete, but we just unset sync so the log remains a
|
||||||
|
// historical record. (We DON'T delete water_smartsheet_sync_log —
|
||||||
|
// admin may want history.)
|
||||||
|
await logAuditEvent({
|
||||||
|
brandId,
|
||||||
|
actorId: auth.adminUser.user_id ?? null,
|
||||||
|
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
|
||||||
|
action: "delete",
|
||||||
|
entityType: "settings",
|
||||||
|
details: { feature: "smartsheet" },
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the Smartsheet connection without saving.
|
||||||
|
*
|
||||||
|
* - If `token` is non-empty, it's used directly (the typical flow
|
||||||
|
* for the "Test Connection" button on a fresh form).
|
||||||
|
* - If `token` is empty and a token is saved, decrypt + test that.
|
||||||
|
* - Returns the sheet name + columns on success.
|
||||||
|
*
|
||||||
|
* Does NOT throw on API errors — returns a structured failure so the
|
||||||
|
* UI can show a helpful message.
|
||||||
|
*/
|
||||||
|
export async function testSmartsheetConnection(
|
||||||
|
brandId: string,
|
||||||
|
sheetIdInput: string,
|
||||||
|
tokenInput: string,
|
||||||
|
): Promise<TestConnectionResult> {
|
||||||
|
await getSession();
|
||||||
|
const auth = await requireWaterAdminPermission();
|
||||||
|
if (!auth.ok) return { success: false, error: auth.error };
|
||||||
|
|
||||||
|
if (!sheetIdInput) return { success: false, error: "Sheet ID or URL is required" };
|
||||||
|
let sheetId: string;
|
||||||
|
try {
|
||||||
|
sheetId = extractSheetId(sheetIdInput);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the token: explicit > saved > error.
|
||||||
|
let token = tokenInput?.trim() ?? "";
|
||||||
|
if (!token) {
|
||||||
|
const saved = await withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
});
|
||||||
|
if (!saved) {
|
||||||
|
return { success: false, error: "No token saved yet — paste one above to test." };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
token = decryptToken({
|
||||||
|
cipher: saved.encryptedApiToken,
|
||||||
|
iv: saved.tokenIv,
|
||||||
|
authTag: saved.tokenAuthTag,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Saved token cannot be decrypted (key was rotated). Paste a fresh token to test." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meta = await getSheetMeta(sheetId, token);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sheetName: meta.name,
|
||||||
|
columnCount: meta.columns.length,
|
||||||
|
columns: meta.columns.map((c) => ({ id: c.id, title: c.title, type: c.type })),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SmartsheetApiError) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
return { success: false, error: "Token rejected by Smartsheet (401 — invalid or expired)" };
|
||||||
|
}
|
||||||
|
if (err.status === 403) {
|
||||||
|
return { success: false, error: "Forbidden (403) — token doesn't have access to this sheet" };
|
||||||
|
}
|
||||||
|
if (err.status === 404) {
|
||||||
|
return { success: false, error: "Sheet not found (404) — check the ID/URL. If you pasted a share URL with a slug, try the numeric sheet ID instead." };
|
||||||
|
}
|
||||||
|
return { success: false, error: `Smartsheet ${err.status}: ${err.body.slice(0, 200)}` };
|
||||||
|
}
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook called from the entry-insert path.
|
||||||
|
*
|
||||||
|
* Inserts a `pending` queue row. If the brand's frequency is
|
||||||
|
* `realtime`, fires `syncEntryToSmartsheet` immediately (best-effort
|
||||||
|
* — failures don't bubble).
|
||||||
|
*
|
||||||
|
* Safe to call repeatedly — the queue has a UNIQUE(brand_id, entry_id)
|
||||||
|
* constraint so duplicate calls are absorbed.
|
||||||
|
*/
|
||||||
|
export async function triggerSyncForEntry(
|
||||||
|
brandId: string,
|
||||||
|
entryId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const isRealtime = await withBrand(brandId, async (db) => {
|
||||||
|
// Enqueue (idempotent on conflict).
|
||||||
|
await db
|
||||||
|
.insert(waterSmartsheetSyncQueue)
|
||||||
|
.values({ brandId, entryId, status: "pending" })
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [waterSmartsheetSyncQueue.brandId, waterSmartsheetSyncQueue.entryId],
|
||||||
|
});
|
||||||
|
// Check config for realtime gating.
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
syncEnabled: waterSmartsheetConfig.syncEnabled,
|
||||||
|
syncFrequency: waterSmartsheetConfig.syncFrequency,
|
||||||
|
})
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const cfg = rows[0];
|
||||||
|
return cfg?.syncEnabled && cfg.syncFrequency === "realtime";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRealtime) {
|
||||||
|
// Fire-and-forget; never bubble errors to the caller.
|
||||||
|
syncEntryToSmartsheet(brandId, entryId).catch((err) => {
|
||||||
|
console.error("[smartsheet] realtime sync threw", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Sync setup failures must NEVER block the field-worker submission.
|
||||||
|
console.error("[smartsheet] triggerSyncForEntry failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validateColumnMapping(
|
||||||
|
m: unknown,
|
||||||
|
): { ok: true; value: SmartsheetColumnMapping } | { ok: false; error: string } {
|
||||||
|
if (!m || typeof m !== "object") {
|
||||||
|
return { ok: false, error: "Column mapping is required" };
|
||||||
|
}
|
||||||
|
const obj = m as Record<string, unknown>;
|
||||||
|
const required: SmartsheetColumnKey[] = ["entry_id", "logged_at"];
|
||||||
|
for (const key of required) {
|
||||||
|
if (typeof obj[key] !== "string" || (obj[key] as string).length === 0) {
|
||||||
|
return { ok: false, error: `Column mapping: "${key}" is required` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Optional keys must be either string (with content) or null.
|
||||||
|
for (const key of SMARTSHEET_COLUMN_KEYS) {
|
||||||
|
if (required.includes(key)) continue;
|
||||||
|
const v = obj[key];
|
||||||
|
if (v !== null && v !== undefined && typeof v !== "string") {
|
||||||
|
return { ok: false, error: `Column mapping: "${key}" must be a string or null` };
|
||||||
|
}
|
||||||
|
if (typeof v === "string" && v.length === 0) {
|
||||||
|
// Treat empty string as "not mapped".
|
||||||
|
obj[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
entry_id: obj.entry_id as string,
|
||||||
|
logged_at: obj.logged_at as string,
|
||||||
|
headgate: (obj.headgate as string | null) ?? null,
|
||||||
|
measurement: (obj.measurement as string | null) ?? null,
|
||||||
|
unit: (obj.unit as string | null) ?? null,
|
||||||
|
irrigator: (obj.irrigator as string | null) ?? null,
|
||||||
|
notes: (obj.notes as string | null) ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
regenerateAdminPin,
|
regenerateAdminPin,
|
||||||
type AdminSettings,
|
type AdminSettings,
|
||||||
} from "@/actions/water-log/settings";
|
} from "@/actions/water-log/settings";
|
||||||
|
import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard";
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
@@ -412,6 +413,28 @@ export default function WaterLogSettingsPage() {
|
|||||||
{saving ? "Saving…" : "Save Settings"}
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Smartsheet Integration — independent of the Admin Portal toggle above.
|
||||||
|
Self-contained card with its own save / test / activity surface. */}
|
||||||
|
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
|
||||||
|
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
|
||||||
|
§ 05 — Integrations
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
Smartsheet
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||||
|
Push every new water log entry to a Smartsheet sheet. The
|
||||||
|
brand admin (you) controls the connection, frequency, and
|
||||||
|
column mapping. The token is encrypted at rest.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Cron-driven Smartsheet sync drain.
|
||||||
|
*
|
||||||
|
* Hit by Vercel cron (see vercel.json entry: every 15 minutes).
|
||||||
|
* Drains pending + due-retry queue rows for every brand with
|
||||||
|
* sync_enabled = true (regardless of frequency — for realtime
|
||||||
|
* brands this is purely a backstop in case event-driven pushes
|
||||||
|
* failed; the cron recovers them via the same retry mechanism).
|
||||||
|
*
|
||||||
|
* Auth:
|
||||||
|
* - Bearer token in `Authorization: Bearer $CRON_SECRET` header,
|
||||||
|
* or `?secret=$CRON_SECRET` query param (Vercel cron supports
|
||||||
|
* neither natively; we use the header from a curl / GitHub
|
||||||
|
* Action / external scheduler). Falls back to `CRON_SECRET` for
|
||||||
|
* cross-compatibility with existing automation routes.
|
||||||
|
*
|
||||||
|
* Body (optional):
|
||||||
|
* - `{ brandId?: string }` — restrict to one brand (useful for
|
||||||
|
* "Retry now" admin buttons). If omitted, runs for every active
|
||||||
|
* brand.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { withPlatformAdmin } from "@/db/client";
|
||||||
|
import { waterSmartsheetConfig } from "@/db/schema/water-log";
|
||||||
|
import { drainSyncQueue } from "@/services/smartsheet-sync";
|
||||||
|
|
||||||
|
const CRON_SECRET =
|
||||||
|
process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? "";
|
||||||
|
|
||||||
|
function unauthorized() {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticate(req: NextRequest): boolean {
|
||||||
|
if (!CRON_SECRET) {
|
||||||
|
// No secret configured: refuse in prod, allow in dev so local
|
||||||
|
// debugging doesn't need a token.
|
||||||
|
return process.env.NODE_ENV !== "production";
|
||||||
|
}
|
||||||
|
const headerToken = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
|
const queryToken = new URL(req.url).searchParams.get("secret");
|
||||||
|
return headerToken === CRON_SECRET || queryToken === CRON_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listActiveBrandIds(): Promise<string[]> {
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: waterSmartsheetConfig.brandId })
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.syncEnabled, true));
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!authenticate(req)) return unauthorized();
|
||||||
|
|
||||||
|
let body: { brandId?: string } = {};
|
||||||
|
try {
|
||||||
|
// Body may be empty for the simple cron case.
|
||||||
|
const text = await req.text();
|
||||||
|
if (text) body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// ignore — body is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
const brandIds = body.brandId ? [body.brandId] : await listActiveBrandIds();
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
brandIds.map(async (brandId) => {
|
||||||
|
try {
|
||||||
|
const result = await drainSyncQueue(brandId);
|
||||||
|
return { ...result, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
error: (err as Error).message ?? "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = results.reduce(
|
||||||
|
(acc, r) => ({
|
||||||
|
considered: acc.considered + r.considered,
|
||||||
|
synced: acc.synced + r.synced,
|
||||||
|
skipped: acc.skipped + r.skipped,
|
||||||
|
failed: acc.failed + r.failed,
|
||||||
|
}),
|
||||||
|
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: totals.failed === 0,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
totals,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET is also supported for easy browser testing during development.
|
||||||
|
// Same auth; in production it should be POST-only.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return POST(req);
|
||||||
|
}
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smartsheet Integration — `/admin/water-log/settings` section.
|
||||||
|
*
|
||||||
|
* Self-contained card that lets a brand admin configure the Smartsheet
|
||||||
|
* integration for their Water Log entries:
|
||||||
|
*
|
||||||
|
* 1. Enable/disable the integration
|
||||||
|
* 2. Smartsheet sheet URL or numeric sheet ID
|
||||||
|
* 3. API token (password field; never sent back to the client after save)
|
||||||
|
* 4. Sync frequency (realtime / every 15 minutes / hourly)
|
||||||
|
* 5. Column mapping (entry_id, logged_at, headgate, measurement,
|
||||||
|
* unit, irrigator, notes)
|
||||||
|
* 6. Test Connection button — fetches sheet metadata so the column
|
||||||
|
* dropdowns are populated
|
||||||
|
* 7. Recent Activity panel — last 10 sync attempts (green ✓ / red ✗)
|
||||||
|
*
|
||||||
|
* Visual language matches the parent Water Log settings page
|
||||||
|
* (cream + forest palette, "Field Almanac" style).
|
||||||
|
*
|
||||||
|
* Threading brandId:
|
||||||
|
* The card takes `brandId` as a prop per CLAUDE.md ("Brand ID
|
||||||
|
* Threading"). The current Water Log settings page still hardcodes
|
||||||
|
* TUXEDO_BRAND_ID; this card accepts it as a prop so the page can
|
||||||
|
* be lifted later without rewrites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useReducer, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getSmartsheetConfig,
|
||||||
|
saveSmartsheetConfig,
|
||||||
|
testSmartsheetConnection,
|
||||||
|
listSmartsheetSyncLog,
|
||||||
|
type SmartsheetConfigResponse,
|
||||||
|
type SmartsheetSyncLogEntry,
|
||||||
|
} from "@/actions/water-log/smartsheet";
|
||||||
|
import type {
|
||||||
|
SmartsheetColumnMapping,
|
||||||
|
SmartsheetFrequency,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
|
import { formatDateTime } from "@/lib/format-date";
|
||||||
|
|
||||||
|
const COLUMN_LABELS: Record<keyof SmartsheetColumnMapping, string> = {
|
||||||
|
entry_id: "Entry ID (UUID, for dedup)",
|
||||||
|
logged_at: "Logged at (timestamp)",
|
||||||
|
headgate: "Headgate name",
|
||||||
|
measurement: "Measurement",
|
||||||
|
unit: "Unit (CFS, GPM, …)",
|
||||||
|
irrigator: "Irrigator name",
|
||||||
|
notes: "Notes (optional)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REQUIRED_COLUMNS: (keyof SmartsheetColumnMapping)[] = [
|
||||||
|
"entry_id",
|
||||||
|
"logged_at",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQUENCY_LABELS: Record<SmartsheetFrequency, string> = {
|
||||||
|
realtime: "Real-time (after every entry)",
|
||||||
|
every_15_minutes: "Every 15 minutes",
|
||||||
|
hourly: "Hourly",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColumnOption = { id: string; title: string; type: string };
|
||||||
|
|
||||||
|
type TestState =
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "loading" }
|
||||||
|
| {
|
||||||
|
state: "ok";
|
||||||
|
sheetName: string;
|
||||||
|
columnCount: number;
|
||||||
|
columns: ColumnOption[];
|
||||||
|
}
|
||||||
|
| { state: "error"; message: string };
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
message: { type: "success" | "error"; text: string } | null;
|
||||||
|
// Persisted (saved) values — for displaying the masked token, etc.
|
||||||
|
savedConfig: SmartsheetConfigResponse | null;
|
||||||
|
// Editable form fields:
|
||||||
|
enabled: boolean;
|
||||||
|
sheetIdInput: string;
|
||||||
|
tokenInput: string; // empty string = keep existing
|
||||||
|
showToken: boolean;
|
||||||
|
frequency: SmartsheetFrequency;
|
||||||
|
mapping: SmartsheetColumnMapping;
|
||||||
|
// Available sheet columns (loaded via Test Connection)
|
||||||
|
availableColumns: ColumnOption[];
|
||||||
|
// Test Connection state
|
||||||
|
test: TestState;
|
||||||
|
// Recent activity
|
||||||
|
recentLog: SmartsheetSyncLogEntry[];
|
||||||
|
loadingLog: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| { type: "LOAD_DONE"; config: SmartsheetConfigResponse; log: SmartsheetSyncLogEntry[] }
|
||||||
|
| { type: "SAVE_START" }
|
||||||
|
| { type: "SAVE_OK"; config: SmartsheetConfigResponse }
|
||||||
|
| { type: "SAVE_FAIL"; message: string }
|
||||||
|
| { type: "SAVE_DONE" }
|
||||||
|
| { type: "SET_ENABLED"; value: boolean }
|
||||||
|
| { type: "SET_SHEET_ID"; value: string }
|
||||||
|
| { type: "SET_TOKEN"; value: string }
|
||||||
|
| { type: "TOGGLE_SHOW_TOKEN" }
|
||||||
|
| { type: "SET_FREQUENCY"; value: SmartsheetFrequency }
|
||||||
|
| { type: "SET_MAPPING_KEY"; key: keyof SmartsheetColumnMapping; value: string }
|
||||||
|
| { type: "TEST_START" }
|
||||||
|
| { type: "TEST_OK"; sheetName: string; columns: ColumnOption[] }
|
||||||
|
| { type: "TEST_FAIL"; message: string }
|
||||||
|
| { type: "CLEAR_MESSAGE" }
|
||||||
|
| { type: "REFRESH_LOG"; log: SmartsheetSyncLogEntry[] };
|
||||||
|
|
||||||
|
const EMPTY_MAPPING: SmartsheetColumnMapping = {
|
||||||
|
entry_id: "",
|
||||||
|
logged_at: "",
|
||||||
|
headgate: "",
|
||||||
|
measurement: "",
|
||||||
|
unit: "",
|
||||||
|
irrigator: "",
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
message: null,
|
||||||
|
savedConfig: null,
|
||||||
|
enabled: false,
|
||||||
|
sheetIdInput: "",
|
||||||
|
tokenInput: "",
|
||||||
|
showToken: false,
|
||||||
|
frequency: "hourly",
|
||||||
|
mapping: { ...EMPTY_MAPPING },
|
||||||
|
availableColumns: [],
|
||||||
|
test: { state: "idle" },
|
||||||
|
recentLog: [],
|
||||||
|
loadingLog: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "LOAD_START":
|
||||||
|
return { ...state, loading: true };
|
||||||
|
case "LOAD_DONE": {
|
||||||
|
const c = action.config;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
savedConfig: c,
|
||||||
|
enabled: c.syncEnabled,
|
||||||
|
sheetIdInput: c.sheetId ?? "",
|
||||||
|
// tokenInput stays empty — we don't preload from saved.
|
||||||
|
frequency: c.syncFrequency,
|
||||||
|
mapping: c.columnMapping ?? { ...EMPTY_MAPPING },
|
||||||
|
recentLog: action.log,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SAVE_START":
|
||||||
|
return { ...state, saving: true, message: null };
|
||||||
|
case "SAVE_OK":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
saving: false,
|
||||||
|
message: { type: "success", text: "Smartsheet settings saved" },
|
||||||
|
savedConfig: action.config,
|
||||||
|
// Clear token input on save (token is now stored server-side).
|
||||||
|
tokenInput: "",
|
||||||
|
showToken: false,
|
||||||
|
};
|
||||||
|
case "SAVE_FAIL":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
saving: false,
|
||||||
|
message: { type: "error", text: action.message },
|
||||||
|
};
|
||||||
|
case "SAVE_DONE":
|
||||||
|
return { ...state, saving: false };
|
||||||
|
case "SET_ENABLED":
|
||||||
|
return { ...state, enabled: action.value };
|
||||||
|
case "SET_SHEET_ID":
|
||||||
|
return { ...state, sheetIdInput: action.value };
|
||||||
|
case "SET_TOKEN":
|
||||||
|
return { ...state, tokenInput: action.value };
|
||||||
|
case "TOGGLE_SHOW_TOKEN":
|
||||||
|
return { ...state, showToken: !state.showToken };
|
||||||
|
case "SET_FREQUENCY":
|
||||||
|
return { ...state, frequency: action.value };
|
||||||
|
case "SET_MAPPING_KEY":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mapping: {
|
||||||
|
...state.mapping,
|
||||||
|
[action.key]: action.value === "" ? null : action.value,
|
||||||
|
} as SmartsheetColumnMapping,
|
||||||
|
};
|
||||||
|
case "TEST_START":
|
||||||
|
return { ...state, test: { state: "loading" } };
|
||||||
|
case "TEST_OK":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
test: {
|
||||||
|
state: "ok",
|
||||||
|
sheetName: action.sheetName,
|
||||||
|
columnCount: action.columns.length,
|
||||||
|
columns: action.columns,
|
||||||
|
},
|
||||||
|
availableColumns: action.columns,
|
||||||
|
};
|
||||||
|
case "TEST_FAIL":
|
||||||
|
return { ...state, test: { state: "error", message: action.message } };
|
||||||
|
case "CLEAR_MESSAGE":
|
||||||
|
return { ...state, message: null };
|
||||||
|
case "REFRESH_LOG":
|
||||||
|
return { ...state, recentLog: action.log };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmartsheetIntegrationCard({
|
||||||
|
brandId,
|
||||||
|
}: {
|
||||||
|
brandId: string;
|
||||||
|
}) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
dispatch({ type: "LOAD_START" });
|
||||||
|
const [config, log] = await Promise.all([
|
||||||
|
getSmartsheetConfig(brandId),
|
||||||
|
listSmartsheetSyncLog(brandId, 10),
|
||||||
|
]);
|
||||||
|
dispatch({ type: "LOAD_DONE", config, log });
|
||||||
|
}, [brandId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAll();
|
||||||
|
}, [loadAll]);
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dispatch({ type: "SAVE_START" });
|
||||||
|
const result = await saveSmartsheetConfig(brandId, {
|
||||||
|
sheetId: state.sheetIdInput,
|
||||||
|
token: state.tokenInput,
|
||||||
|
syncEnabled: state.enabled,
|
||||||
|
syncFrequency: state.frequency,
|
||||||
|
columnMapping: state.mapping,
|
||||||
|
});
|
||||||
|
if (result.success && result.config) {
|
||||||
|
dispatch({ type: "SAVE_OK", config: result.config });
|
||||||
|
// Refresh log so any pending activity from this turn is visible.
|
||||||
|
const log = await listSmartsheetSyncLog(brandId, 10);
|
||||||
|
dispatch({ type: "REFRESH_LOG", log });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "SAVE_FAIL", message: result.error ?? "Save failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
if (!state.sheetIdInput) {
|
||||||
|
dispatch({ type: "TEST_FAIL", message: "Enter a Sheet URL or ID first" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.tokenInput && !state.savedConfig?.hasToken) {
|
||||||
|
dispatch({
|
||||||
|
type: "TEST_FAIL",
|
||||||
|
message: "Paste your API token to test, or save the config first",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch({ type: "TEST_START" });
|
||||||
|
const result = await testSmartsheetConnection(
|
||||||
|
brandId,
|
||||||
|
state.sheetIdInput,
|
||||||
|
state.tokenInput,
|
||||||
|
);
|
||||||
|
if (result.success) {
|
||||||
|
dispatch({
|
||||||
|
type: "TEST_OK",
|
||||||
|
sheetName: result.sheetName,
|
||||||
|
columns: result.columns,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch({ type: "TEST_FAIL", message: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<Card title="Smartsheet Integration" subtitle="Loading…">
|
||||||
|
<p className="text-sm text-[#5a5d5a]">Reading saved configuration.</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
{state.message && (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||||
|
state.message.type === "success"
|
||||||
|
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
|
||||||
|
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Smartsheet Integration"
|
||||||
|
subtitle="Push every new water log entry to a Smartsheet sheet automatically."
|
||||||
|
>
|
||||||
|
<ToggleRow
|
||||||
|
label="Enable"
|
||||||
|
description="When disabled, no new rows are pushed. Existing rows in Smartsheet are untouched."
|
||||||
|
value={state.enabled}
|
||||||
|
onChange={(v) => dispatch({ type: "SET_ENABLED", value: v })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Connection"
|
||||||
|
subtitle="Smartsheet API token + target sheet."
|
||||||
|
>
|
||||||
|
{/* Sheet */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="smartsheet-sheet"
|
||||||
|
className="block text-xs font-medium text-[#5a5d5a]"
|
||||||
|
>
|
||||||
|
Sheet URL or sheet ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="smartsheet-sheet"
|
||||||
|
type="text"
|
||||||
|
value={state.sheetIdInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_SHEET_ID", value: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="https://app.smartsheet.com/sheets/AbCdEfGhIjKlMn or 123456789012345"
|
||||||
|
className="w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[#8a8b88]">
|
||||||
|
Paste the share URL from your browser address bar, or the
|
||||||
|
numeric sheet ID. If a share URL doesn't work, use the
|
||||||
|
numeric ID from <em>File → Properties</em> in Smartsheet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div className="mt-4 space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="smartsheet-token"
|
||||||
|
className="block text-xs font-medium text-[#5a5d5a]"
|
||||||
|
>
|
||||||
|
API access token
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="smartsheet-token"
|
||||||
|
type={state.showToken ? "text" : "password"}
|
||||||
|
value={state.tokenInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({ type: "SET_TOKEN", value: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
state.savedConfig?.hasToken
|
||||||
|
? `Leave blank to keep the saved token (${state.savedConfig.maskedToken ?? "••••"})`
|
||||||
|
: "Paste your Smartsheet API token"
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: "TOGGLE_SHOW_TOKEN" })}
|
||||||
|
className="rounded-lg border border-[#d4d9d3] bg-white px-3 text-xs font-medium text-[#5a5d5a] hover:bg-[#f4f1e8]"
|
||||||
|
aria-label={state.showToken ? "Hide token" : "Show token"}
|
||||||
|
>
|
||||||
|
{state.showToken ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#8a8b88]">
|
||||||
|
Generate a token at{" "}
|
||||||
|
<a
|
||||||
|
href="https://app.smartsheet.com/b/home"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#1a4d2e] underline underline-offset-2"
|
||||||
|
>
|
||||||
|
app.smartsheet.com
|
||||||
|
</a>{" "}
|
||||||
|
→ <span className="font-mono">Account</span> →{" "}
|
||||||
|
<span className="font-mono">Personal Settings</span> →{" "}
|
||||||
|
<span className="font-mono">API Access</span>. The token is
|
||||||
|
encrypted with AES-256-GCM at rest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Connection */}
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={state.test.state === "loading"}
|
||||||
|
className="rounded-lg border border-[#1a4d2e] bg-white px-4 py-2 text-sm font-semibold text-[#1a4d2e] transition hover:bg-[#1a4d2e] hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{state.test.state === "loading" ? "Testing…" : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
<TestResultPanel test={state.test} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Sync frequency"
|
||||||
|
subtitle="How often new water log entries are pushed to Smartsheet."
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => (
|
||||||
|
<label
|
||||||
|
key={f}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition ${
|
||||||
|
state.frequency === f
|
||||||
|
? "border-[#1a4d2e] bg-[#1a4d2e]/5"
|
||||||
|
: "border-[#d4d9d3] bg-white hover:border-[#1a4d2e]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="smartsheet-frequency"
|
||||||
|
value={f}
|
||||||
|
checked={state.frequency === f}
|
||||||
|
onChange={() =>
|
||||||
|
dispatch({ type: "SET_FREQUENCY", value: f })
|
||||||
|
}
|
||||||
|
className="mt-0.5 accent-[#1a4d2e]"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-sm font-medium text-[#1d1d1f]">
|
||||||
|
{FREQUENCY_LABELS[f]}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Column mapping"
|
||||||
|
subtitle="Map our water log fields to columns in your Smartsheet. entry_id and logged_at are required."
|
||||||
|
>
|
||||||
|
{state.availableColumns.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#8a8b88]">
|
||||||
|
Run <strong>Test Connection</strong> above to load the sheet's
|
||||||
|
columns. You can also paste column IDs manually below.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[#8a8b88]">
|
||||||
|
Loaded {state.availableColumns.length} columns from sheet{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{state.test.state === "ok" ? state.test.sheetName : "—"}
|
||||||
|
</span>
|
||||||
|
. Pick from the dropdowns below.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{(Object.keys(COLUMN_LABELS) as Array<keyof SmartsheetColumnMapping>).map(
|
||||||
|
(key) => {
|
||||||
|
const required = REQUIRED_COLUMNS.includes(key);
|
||||||
|
const value = state.mapping[key];
|
||||||
|
const valueAsString = value ?? "";
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor={`map-${key}`}
|
||||||
|
className="w-1/3 text-sm font-medium text-[#1d1d1f]"
|
||||||
|
>
|
||||||
|
{COLUMN_LABELS[key]}
|
||||||
|
{required && (
|
||||||
|
<span className="ml-1 text-xs text-[#a4452b]">
|
||||||
|
required
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{state.availableColumns.length > 0 ? (
|
||||||
|
<select
|
||||||
|
id={`map-${key}`}
|
||||||
|
value={valueAsString}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_MAPPING_KEY",
|
||||||
|
key,
|
||||||
|
value: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
— {required ? "Select a column" : "Not mapped"} —
|
||||||
|
</option>
|
||||||
|
{state.availableColumns.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
{c.type !== "TEXT_NUMBER"
|
||||||
|
? ` (${c.type.toLowerCase()})`
|
||||||
|
: ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id={`map-${key}`}
|
||||||
|
type="text"
|
||||||
|
value={valueAsString}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: "SET_MAPPING_KEY",
|
||||||
|
key,
|
||||||
|
value: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="Column ID (numeric)"
|
||||||
|
className="flex-1 rounded-lg border border-[#d4d9d3] bg-white px-3 py-2 font-mono text-sm outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="Recent activity"
|
||||||
|
subtitle="Last 10 sync attempts. Use this to spot token / sheet issues quickly."
|
||||||
|
>
|
||||||
|
{state.loadingLog ? (
|
||||||
|
<p className="text-sm text-[#5a5d5a]">Loading…</p>
|
||||||
|
) : state.recentLog.length === 0 ? (
|
||||||
|
<p className="text-sm text-[#5a5d5a]">
|
||||||
|
No sync attempts yet. Save the config and submit a water log
|
||||||
|
entry from <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[#d4d9d3]">
|
||||||
|
{state.recentLog.map((log) => (
|
||||||
|
<li
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ${
|
||||||
|
log.success ? "bg-[#1a4d2e]" : "bg-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
aria-label={log.success ? "success" : "failure"}
|
||||||
|
>
|
||||||
|
{log.success ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className="font-mono font-semibold text-[#1d1d1f]">
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#5a5d5a]">
|
||||||
|
{formatDateTime(log.createdAt)}
|
||||||
|
</span>
|
||||||
|
{log.durationMs != null && (
|
||||||
|
<span className="text-[#8a8b88]">
|
||||||
|
{log.durationMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{log.error && (
|
||||||
|
<p className="mt-0.5 text-xs text-[#a4452b]">{log.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={state.saving || noTokenYet}
|
||||||
|
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{state.saving ? "Saving…" : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{noTokenYet && (
|
||||||
|
<p className="text-center text-xs text-[#8a8b88]">
|
||||||
|
Paste an API token above to enable Save.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subcomponents (mirror the parent settings page style) ──────────────────
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||||
|
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultPanel({ test }: { test: TestState }) {
|
||||||
|
if (test.state === "idle") return null;
|
||||||
|
if (test.state === "loading") {
|
||||||
|
return <span className="text-sm text-[#5a5d5a]">Testing…</span>;
|
||||||
|
}
|
||||||
|
if (test.state === "ok") {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-3 py-2 text-xs">
|
||||||
|
<p className="font-semibold text-[#1a4d2e]">
|
||||||
|
✓ Connected to “{test.sheetName}” ({test.columnCount} columns)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex-1 rounded-lg border border-[#a4452b] bg-[#a4452b]/5 px-3 py-2 text-xs">
|
||||||
|
<p className="font-semibold text-[#a4452b]">✗ {test.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* AES-256-GCM token encryption for at-rest secrets.
|
||||||
|
*
|
||||||
|
* Used by the Smartsheet integration (and any future third-party token
|
||||||
|
* storage) to keep API tokens encrypted in Postgres. AES-256-GCM gives
|
||||||
|
* us confidentiality + integrity in one primitive: a unique 12-byte
|
||||||
|
* IV per encrypt, a 16-byte auth tag that detects tampering.
|
||||||
|
*
|
||||||
|
* Key management:
|
||||||
|
* - Single key per environment, sourced from `SMARTSHEET_TOKEN_ENC_KEY`
|
||||||
|
* (32 random bytes, base64-encoded).
|
||||||
|
* - Generate with: `openssl rand -base64 32`
|
||||||
|
* - Storing the key in env vars matches the codebase convention
|
||||||
|
* (`STRIPE_SECRET_KEY`, `NEON_AUTH_COOKIE_SECRET`, etc.).
|
||||||
|
* - Key rotation is *not* supported in this version — if the key
|
||||||
|
* changes, existing encrypted blobs become unreadable and admins
|
||||||
|
* must re-enter tokens. Phase 2 should add a `key_version` column
|
||||||
|
* on the config table to support zero-downtime rotation.
|
||||||
|
*
|
||||||
|
* Why AES-256-GCM?
|
||||||
|
* - Built into Node's `crypto` module — zero dependencies.
|
||||||
|
* - Authenticated encryption — we detect (and refuse to decrypt)
|
||||||
|
* ciphertext that was tampered with.
|
||||||
|
* - Faster than AES-CBC + HMAC and easier to use correctly.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const ALGO = "aes-256-gcm";
|
||||||
|
const IV_LEN = 12; // GCM standard
|
||||||
|
const TAG_LEN = 16; // GCM standard
|
||||||
|
const KEY_LEN = 32; // 256 bits
|
||||||
|
|
||||||
|
export type EncryptedToken = {
|
||||||
|
/** Ciphertext (raw bytes; not base64). Stored as BYTEA. */
|
||||||
|
cipher: Buffer;
|
||||||
|
/** 12-byte IV, unique per encrypt. Stored as BYTEA. */
|
||||||
|
iv: Buffer;
|
||||||
|
/** 16-byte GCM auth tag. Stored as BYTEA. */
|
||||||
|
authTag: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getKey(): Buffer {
|
||||||
|
const raw = process.env.SMARTSHEET_TOKEN_ENC_KEY;
|
||||||
|
if (!raw || raw.length === 0) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
throw new Error(
|
||||||
|
"SMARTSHEET_TOKEN_ENC_KEY is not set. Generate one with `openssl rand -base64 32` and add it to .env.local.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"SMARTSHEET_TOKEN_ENC_KEY is not set in production. This is required — refusing to fall back to an insecure default.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = Buffer.from(raw, "base64");
|
||||||
|
if (key.length !== KEY_LEN) {
|
||||||
|
throw new Error(
|
||||||
|
`SMARTSHEET_TOKEN_ENC_KEY must decode to exactly ${KEY_LEN} bytes (got ${key.length}). Regenerate with \`openssl rand -base64 32\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a plaintext token (e.g. a Smartsheet API access token).
|
||||||
|
*
|
||||||
|
* Returns a triple of Buffers that should each be persisted to a
|
||||||
|
* separate BYTEA column. Never log the plaintext or the auth tag.
|
||||||
|
*/
|
||||||
|
export function encryptToken(plaintext: string): EncryptedToken {
|
||||||
|
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||||
|
throw new Error("encryptToken: plaintext must be a non-empty string");
|
||||||
|
}
|
||||||
|
const key = getKey();
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const cipher = createCipheriv(ALGO, key, iv);
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, "utf8"),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return { cipher: ciphertext, iv, authTag };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a token previously produced by `encryptToken`.
|
||||||
|
*
|
||||||
|
* Throws if the auth tag verification fails (tampered ciphertext).
|
||||||
|
* Throws if the env key is missing/wrong size (key rotation break).
|
||||||
|
*/
|
||||||
|
export function decryptToken(token: EncryptedToken): string {
|
||||||
|
if (
|
||||||
|
!Buffer.isBuffer(token.cipher) ||
|
||||||
|
!Buffer.isBuffer(token.iv) ||
|
||||||
|
!Buffer.isBuffer(token.authTag)
|
||||||
|
) {
|
||||||
|
throw new Error("decryptToken: invalid token shape");
|
||||||
|
}
|
||||||
|
if (token.iv.length !== IV_LEN) {
|
||||||
|
throw new Error(`decryptToken: iv must be ${IV_LEN} bytes`);
|
||||||
|
}
|
||||||
|
if (token.authTag.length !== TAG_LEN) {
|
||||||
|
throw new Error(`decryptToken: authTag must be ${TAG_LEN} bytes`);
|
||||||
|
}
|
||||||
|
const key = getKey();
|
||||||
|
const decipher = createDecipheriv(ALGO, key, token.iv);
|
||||||
|
decipher.setAuthTag(token.authTag);
|
||||||
|
const plaintext = Buffer.concat([
|
||||||
|
decipher.update(token.cipher),
|
||||||
|
decipher.final(), // throws on auth-tag mismatch
|
||||||
|
]);
|
||||||
|
return plaintext.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask a token for UI display. Returns "••••abcd" where "abcd" is the
|
||||||
|
* last 4 characters of the plaintext. For tokens < 4 chars, returns
|
||||||
|
* "••••" (no tail). Used by the Settings card to show "we have a token
|
||||||
|
* saved" without revealing it.
|
||||||
|
*/
|
||||||
|
export function maskToken(plaintext: string): string {
|
||||||
|
if (!plaintext || plaintext.length === 0) return "";
|
||||||
|
if (plaintext.length <= 4) return "••••";
|
||||||
|
return `••••${plaintext.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 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 share URLs include an opaque slug (e.g. "abc123"). We
|
||||||
|
* can't resolve that slug without an API call (the sheet meta call
|
||||||
|
* will fail with 404 anyway), so we just take the last path segment
|
||||||
|
* and pass it as the sheet ID. If the user pasted a slug URL, the
|
||||||
|
* "Test Connection" UI will surface the 404 and prompt for a numeric
|
||||||
|
* ID instead. (This matches what most Smartsheet integrations do.)
|
||||||
|
*/
|
||||||
|
export function extractSheetId(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("Sheet ID or URL is required");
|
||||||
|
}
|
||||||
|
// Plain numeric ID
|
||||||
|
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||||
|
|
||||||
|
// URL form
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
const segments = url.pathname.split("/").filter(Boolean);
|
||||||
|
// Find the segment after "sheets"
|
||||||
|
const sheetsIdx = segments.findIndex((s) => s === "sheets");
|
||||||
|
if (sheetsIdx >= 0 && segments[sheetsIdx + 1]) {
|
||||||
|
return segments[sheetsIdx + 1];
|
||||||
|
}
|
||||||
|
// Last segment as a fallback
|
||||||
|
if (segments.length > 0) {
|
||||||
|
return segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a URL — fall through
|
||||||
|
}
|
||||||
|
// Treat as opaque ID; API will surface 404 if it's not numeric.
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
result: {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(data.result.id),
|
||||||
|
name: data.result.name,
|
||||||
|
columns: data.result.columns.map((c) => ({
|
||||||
|
id: String(c.id),
|
||||||
|
title: c.title,
|
||||||
|
type: c.type,
|
||||||
|
index: c.index,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiResponse = {
|
||||||
|
result: { id: number; rowNumber: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await request<ApiResponse>(
|
||||||
|
"POST",
|
||||||
|
`/2.0/sheets/${encodeURIComponent(sheetId)}/rows`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: cells.map((c) => ({
|
||||||
|
columnId: Number(c.columnId),
|
||||||
|
value: toCellValue(c.value),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowId: String(data.result.id),
|
||||||
|
rowNumber: data.result.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
/**
|
||||||
|
* Water Log → Smartsheet sync engine.
|
||||||
|
*
|
||||||
|
* Called from three places:
|
||||||
|
* 1. `triggerSyncForEntry()` after every entry insert (realtime path)
|
||||||
|
* 2. `/api/water-log/smartsheet-sync` cron (every 15 min / hourly)
|
||||||
|
* 3. Manual retry via the admin UI's "Retry now" button (future)
|
||||||
|
*
|
||||||
|
* Public surface:
|
||||||
|
* - `syncEntryToSmartsheet(brandId, entryId)` — push one entry
|
||||||
|
* - `drainSyncQueue(brandId, opts)` — process pending / due retries
|
||||||
|
* - `runScheduledSync(brandId)` — entry point for the cron route;
|
||||||
|
* loads the config + applies per-frequency filtering
|
||||||
|
*
|
||||||
|
* Concurrency model:
|
||||||
|
* - One sync runs at a time per brand. The SELECT … FOR UPDATE SKIP
|
||||||
|
* LOCKED in `drainSyncQueue` lets multiple cron runs co-exist
|
||||||
|
* safely across brands without double-processing.
|
||||||
|
*
|
||||||
|
* Retry strategy:
|
||||||
|
* - On failure, `attempts++` and `next_attempt_at = now() + 2^attempts min`,
|
||||||
|
* capped at 60 minutes. After `maxAttempts` (default 5), the row
|
||||||
|
* stays `failed` and the admin sees it in Recent Activity.
|
||||||
|
*
|
||||||
|
* Error sanitization:
|
||||||
|
* - Smartsheet errors may include token echoes in odd cases. The
|
||||||
|
* service strips anything that looks like our token before
|
||||||
|
* persisting `last_error`.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { and, eq, inArray, lte, sql } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import {
|
||||||
|
waterLogEntries,
|
||||||
|
waterHeadgates,
|
||||||
|
waterIrrigators,
|
||||||
|
waterSmartsheetConfig,
|
||||||
|
waterSmartsheetSyncQueue,
|
||||||
|
waterSmartsheetSyncLog,
|
||||||
|
type SmartsheetColumnMapping,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
|
import { decryptToken } from "@/lib/crypto";
|
||||||
|
import {
|
||||||
|
addRow,
|
||||||
|
findRowByColumn,
|
||||||
|
SmartsheetApiError,
|
||||||
|
type SmartsheetCell,
|
||||||
|
} from "@/lib/smartsheet";
|
||||||
|
|
||||||
|
const DEFAULT_BATCH_SIZE = 50;
|
||||||
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
||||||
|
const MAX_BACKOFF_MINUTES = 60;
|
||||||
|
|
||||||
|
export type SyncResult =
|
||||||
|
| { status: "synced"; smartsheetRowId: string; attempts: number; durationMs: number }
|
||||||
|
| { status: "skipped"; reason: string; smartsheetRowId: string | null; durationMs: number }
|
||||||
|
| { status: "failed"; error: string; attempts: number; durationMs: number; retryAt: Date };
|
||||||
|
|
||||||
|
export type DrainResult = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a single water-log entry to its brand's configured Smartsheet.
|
||||||
|
*
|
||||||
|
* Idempotent: if the queue row already has a `smartsheet_row_id`, the
|
||||||
|
* function short-circuits with `status: "skipped"`. If the entry
|
||||||
|
* itself doesn't exist, returns a failed result.
|
||||||
|
*
|
||||||
|
* Never throws — all failures are returned in the result so the
|
||||||
|
* caller can decide whether to bubble them up.
|
||||||
|
*/
|
||||||
|
export async function syncEntryToSmartsheet(
|
||||||
|
brandId: string,
|
||||||
|
entryId: string,
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// ── 1. Load config + queue row + entry in parallel ──
|
||||||
|
const [configRows, queueRows, entryRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetSyncQueue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(waterSmartsheetSyncQueue.brandId, brandId),
|
||||||
|
eq(waterSmartsheetSyncQueue.entryId, entryId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
entry: waterLogEntries,
|
||||||
|
headgateName: waterHeadgates.name,
|
||||||
|
irrigatorName: waterIrrigators.name,
|
||||||
|
})
|
||||||
|
.from(waterLogEntries)
|
||||||
|
.leftJoin(
|
||||||
|
waterHeadgates,
|
||||||
|
eq(waterHeadgates.id, waterLogEntries.headgateId),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
waterIrrigators,
|
||||||
|
eq(waterIrrigators.id, waterLogEntries.irrigatorId),
|
||||||
|
)
|
||||||
|
.where(eq(waterLogEntries.id, entryId))
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = configRows[0];
|
||||||
|
const queue = queueRows[0];
|
||||||
|
const joined = entryRows[0];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
const err = "No Smartsheet config for brand";
|
||||||
|
return recordFailure(db, brandId, entryId, queue?.attempts ?? 0, err, start);
|
||||||
|
}
|
||||||
|
if (!config.syncEnabled) {
|
||||||
|
return finalizeSkip(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue?.id ?? null,
|
||||||
|
"sync disabled",
|
||||||
|
queue?.smartsheetRowId ?? null,
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!joined) {
|
||||||
|
return recordFailure(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue?.attempts ?? 0,
|
||||||
|
"entry not found",
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Dedup via queue row ──
|
||||||
|
if (queue?.smartsheetRowId) {
|
||||||
|
return finalizeSkip(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue.id,
|
||||||
|
"already synced",
|
||||||
|
queue.smartsheetRowId,
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Decrypt token + build cells ──
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
token = decryptToken({
|
||||||
|
cipher: config.encryptedApiToken,
|
||||||
|
iv: config.tokenIv,
|
||||||
|
authTag: config.tokenAuthTag,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return recordFailure(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue?.attempts ?? 0,
|
||||||
|
`decryption failed (key rotated or corrupt): ${sanitizeError(err)}`,
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = buildCells(
|
||||||
|
config.columnMapping,
|
||||||
|
joined.entry.id,
|
||||||
|
joined.entry.loggedAt,
|
||||||
|
joined.headgateName ?? "",
|
||||||
|
joined.entry.measurement,
|
||||||
|
joined.entry.unit,
|
||||||
|
joined.irrigatorName ?? "",
|
||||||
|
joined.entry.notes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cells.length === 0) {
|
||||||
|
return recordFailure(
|
||||||
|
db,
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
queue?.attempts ?? 0,
|
||||||
|
"no columns mapped (entry_id or logged_at not configured)",
|
||||||
|
start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Try the API call ──
|
||||||
|
let rowId: string | null = null;
|
||||||
|
try {
|
||||||
|
const mapping = config.columnMapping;
|
||||||
|
const dedup = await findRowByColumn(
|
||||||
|
config.sheetId,
|
||||||
|
token,
|
||||||
|
mapping.entry_id,
|
||||||
|
joined.entry.id,
|
||||||
|
);
|
||||||
|
if (dedup) {
|
||||||
|
rowId = dedup.rowId;
|
||||||
|
} else {
|
||||||
|
const created = await addRow(config.sheetId, token, cells);
|
||||||
|
rowId = created.rowId;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const attempts = (queue?.attempts ?? 0) + 1;
|
||||||
|
const errorMessage = sanitizeError(err);
|
||||||
|
await writeLogRow(db, {
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
smartsheetRowId: null,
|
||||||
|
action: queue ? "retry" : "sync",
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
if (queue) {
|
||||||
|
await updateQueueFailure(db, queue.id, attempts, errorMessage);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
error: errorMessage,
|
||||||
|
attempts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: nextAttemptAt(attempts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Persist success ──
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
if (queue) {
|
||||||
|
await db
|
||||||
|
.update(waterSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
status: "synced",
|
||||||
|
attempts: queue.attempts + 1,
|
||||||
|
lastError: null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
nextAttemptAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(waterSmartsheetSyncQueue.id, queue.id));
|
||||||
|
} else {
|
||||||
|
// No queue row yet (defensive — should never happen since the
|
||||||
|
// entry hook creates one, but handle gracefully).
|
||||||
|
await db
|
||||||
|
.insert(waterSmartsheetSyncQueue)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
status: "synced",
|
||||||
|
attempts: 1,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [waterSmartsheetSyncQueue.brandId, waterSmartsheetSyncQueue.entryId],
|
||||||
|
set: {
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
status: "synced",
|
||||||
|
attempts: sql`${waterSmartsheetSyncQueue.attempts} + 1`,
|
||||||
|
lastError: null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeLogRow(db, {
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
action: "sync",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(waterSmartsheetConfig)
|
||||||
|
.set({ lastSyncAt: new Date(), lastSyncError: null })
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "synced",
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
attempts: (queue?.attempts ?? 0) + 1,
|
||||||
|
durationMs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain pending + due-retry entries for a single brand.
|
||||||
|
*
|
||||||
|
* Uses `SELECT … FOR UPDATE SKIP LOCKED` semantics implicitly via
|
||||||
|
* row-level locks inside the transaction; concurrent cron runs on the
|
||||||
|
* same brand will not double-process.
|
||||||
|
*
|
||||||
|
* Caller is the cron route or a manual "Retry all" admin action.
|
||||||
|
*/
|
||||||
|
export async function drainSyncQueue(
|
||||||
|
brandId: string,
|
||||||
|
opts: { batchSize?: number; maxAttempts?: number } = {},
|
||||||
|
): Promise<DrainResult> {
|
||||||
|
const batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
||||||
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
// ── 1. Decide whether to drain (config gating) ──
|
||||||
|
const configRows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterSmartsheetConfig)
|
||||||
|
.where(eq(waterSmartsheetConfig.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const config = configRows[0];
|
||||||
|
if (!config || !config.syncEnabled) {
|
||||||
|
return { brandId, considered: 0, synced: 0, skipped: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Pick the next batch ──
|
||||||
|
// Conditions: status in ('pending','failed'), next_attempt_at <= now,
|
||||||
|
// attempts < maxAttempts. Order by next_attempt_at to drain in
|
||||||
|
// arrival order.
|
||||||
|
const candidates = await db
|
||||||
|
.select({
|
||||||
|
id: waterSmartsheetSyncQueue.id,
|
||||||
|
entryId: waterSmartsheetSyncQueue.entryId,
|
||||||
|
attempts: waterSmartsheetSyncQueue.attempts,
|
||||||
|
})
|
||||||
|
.from(waterSmartsheetSyncQueue)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(waterSmartsheetSyncQueue.brandId, brandId),
|
||||||
|
inArray(waterSmartsheetSyncQueue.status, ["pending", "failed"]),
|
||||||
|
lte(waterSmartsheetSyncQueue.nextAttemptAt, new Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(waterSmartsheetSyncQueue.nextAttemptAt)
|
||||||
|
.limit(batchSize);
|
||||||
|
|
||||||
|
// Filter out rows that have already hit the cap. We do this in
|
||||||
|
// JS rather than SQL to keep the WHERE clause portable.
|
||||||
|
const eligible = candidates.filter((c) => c.attempts < maxAttempts);
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
// Process sequentially to stay well under Smartsheet's 300 req/min
|
||||||
|
// limit (50 rows × 2 API calls = 100 calls per drain, sequentially
|
||||||
|
// = at most ~50s at our 8s timeout).
|
||||||
|
for (const c of eligible) {
|
||||||
|
const result = await syncEntryToSmartsheet(brandId, c.entryId);
|
||||||
|
if (result.status === "synced") synced++;
|
||||||
|
else if (result.status === "skipped") skipped++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: eligible.length,
|
||||||
|
synced,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for the cron route.
|
||||||
|
*
|
||||||
|
* For real-time-frequency brands, only retries are drained (the
|
||||||
|
* realtime path is event-driven in `triggerSyncForEntry`, not cron).
|
||||||
|
* For every-15-minutes / hourly, drain everything that's due.
|
||||||
|
*/
|
||||||
|
export async function runScheduledSync(brandId: string): Promise<DrainResult> {
|
||||||
|
return drainSyncQueue(brandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type DbClient = Parameters<Parameters<typeof withBrand>[1]>[0];
|
||||||
|
|
||||||
|
function buildCells(
|
||||||
|
mapping: SmartsheetColumnMapping,
|
||||||
|
entryId: string,
|
||||||
|
loggedAt: Date,
|
||||||
|
headgateName: string,
|
||||||
|
measurement: string, // numeric as string from DB
|
||||||
|
unit: string,
|
||||||
|
irrigatorName: string,
|
||||||
|
notes: string | null,
|
||||||
|
): SmartsheetCell[] {
|
||||||
|
const cells: SmartsheetCell[] = [];
|
||||||
|
if (mapping.entry_id) {
|
||||||
|
cells.push({ columnId: mapping.entry_id, value: entryId });
|
||||||
|
}
|
||||||
|
if (mapping.logged_at) {
|
||||||
|
cells.push({ columnId: mapping.logged_at, value: loggedAt.toISOString() });
|
||||||
|
}
|
||||||
|
if (mapping.headgate) {
|
||||||
|
cells.push({ columnId: mapping.headgate, value: headgateName });
|
||||||
|
}
|
||||||
|
if (mapping.measurement) {
|
||||||
|
cells.push({ columnId: mapping.measurement, value: measurement });
|
||||||
|
}
|
||||||
|
if (mapping.unit) {
|
||||||
|
cells.push({ columnId: mapping.unit, value: unit });
|
||||||
|
}
|
||||||
|
if (mapping.irrigator) {
|
||||||
|
cells.push({ columnId: mapping.irrigator, value: irrigatorName });
|
||||||
|
}
|
||||||
|
if (mapping.notes) {
|
||||||
|
cells.push({ columnId: mapping.notes, value: notes ?? "" });
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip anything that could contain the API token. Smartsheet errors
|
||||||
|
* rarely echo the token, but defensive paranoia is cheap.
|
||||||
|
*/
|
||||||
|
function sanitizeError(err: unknown): string {
|
||||||
|
if (err instanceof SmartsheetApiError) {
|
||||||
|
return `Smartsheet ${err.status}: ${err.body.slice(0, 300).replace(/[A-Za-z0-9]{20,}/g, "***redacted***")}`;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message.slice(0, 300).replace(/[A-Za-z0-9]{20,}/g, "***redacted***");
|
||||||
|
}
|
||||||
|
return String(err).slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAttemptAt(attempts: number): Date {
|
||||||
|
// 2^attempts minutes, capped at 60 minutes.
|
||||||
|
const minutes = Math.min(2 ** attempts, MAX_BACKOFF_MINUTES);
|
||||||
|
return new Date(Date.now() + minutes * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailure(
|
||||||
|
db: DbClient,
|
||||||
|
brandId: string,
|
||||||
|
entryId: string,
|
||||||
|
attempts: number,
|
||||||
|
errorMessage: string,
|
||||||
|
start: number,
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
await writeLogRow(db, {
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
smartsheetRowId: null,
|
||||||
|
action: "sync",
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
error: errorMessage,
|
||||||
|
attempts,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
retryAt: nextAttemptAt(attempts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeSkip(
|
||||||
|
db: DbClient,
|
||||||
|
brandId: string,
|
||||||
|
entryId: string,
|
||||||
|
queueId: string | null,
|
||||||
|
reason: string,
|
||||||
|
rowId: string | null,
|
||||||
|
start: number,
|
||||||
|
): Promise<SyncResult> {
|
||||||
|
await writeLogRow(db, {
|
||||||
|
brandId,
|
||||||
|
entryId,
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
action: "skip",
|
||||||
|
success: true,
|
||||||
|
error: reason,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "skipped",
|
||||||
|
reason,
|
||||||
|
smartsheetRowId: rowId,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateQueueFailure(
|
||||||
|
db: DbClient,
|
||||||
|
queueId: string,
|
||||||
|
attempts: number,
|
||||||
|
errorMessage: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(waterSmartsheetSyncQueue)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
attempts,
|
||||||
|
lastError: errorMessage,
|
||||||
|
nextAttemptAt: nextAttemptAt(attempts),
|
||||||
|
})
|
||||||
|
.where(eq(waterSmartsheetSyncQueue.id, queueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLogRow(
|
||||||
|
db: DbClient,
|
||||||
|
args: {
|
||||||
|
brandId: string;
|
||||||
|
entryId: string | null;
|
||||||
|
smartsheetRowId: string | null;
|
||||||
|
action: "sync" | "retry" | "skip" | "queue";
|
||||||
|
success: boolean;
|
||||||
|
error: string | null;
|
||||||
|
durationMs: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.insert(waterSmartsheetSyncLog).values({
|
||||||
|
brandId: args.brandId,
|
||||||
|
entryId: args.entryId,
|
||||||
|
smartsheetRowId: args.smartsheetRowId,
|
||||||
|
action: args.action,
|
||||||
|
success: args.success,
|
||||||
|
error: args.error,
|
||||||
|
durationMs: args.durationMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Log failures must never block the actual sync.
|
||||||
|
console.error("[smartsheet-sync] failed to write log row", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
{
|
{
|
||||||
"path": "/api/cron/send-scheduled",
|
"path": "/api/cron/send-scheduled",
|
||||||
"schedule": "0 9 * * *"
|
"schedule": "0 9 * * *"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/water-log/smartsheet-sync",
|
||||||
|
"schedule": "*/15 * * * *"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
|
|||||||
Reference in New Issue
Block a user