Files
route-commerce/src/components/admin/water-log/SmartsheetIntegrationCard.tsx
T
Nora fc70344dab
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s
feat(water-log): Smartsheet sync integration
Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.

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

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

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

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

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

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

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

690 lines
23 KiB
TypeScript

"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&apos;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&apos;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 &ldquo;{test.sheetName}&rdquo; ({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>
);
}