feat(water-log): Smartsheet sync integration
Deploy to route.crispygoat.com / deploy (push) Successful in 5m34s

Per-brand Smartsheet integration that pushes new water_log_entries to a
configured sheet. Config UI lives at /admin/water-log/settings.

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

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

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

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

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

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

Rollback: remove cron entry, DROP the 3 tables, revert field.ts +
settings page hooks, git revert the merge commit. See MEMORY.md
for full rollback story + sharp edges (no key rotation, slug URLs).
This commit is contained in:
Nora
2026-07-03 14:42:15 -06:00
parent 3b0d0c07e3
commit fc70344dab
13 changed files with 2794 additions and 1 deletions
+15
View File
@@ -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
+87
View File
@@ -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
View File
@@ -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;
+5
View File
@@ -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 };
}); });
} }
+492
View File
@@ -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,
},
};
}
+23
View File
@@ -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&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>
);
}
+125
View File
@@ -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)}`;
}
+324
View File
@@ -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;
}
+547
View File
@@ -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);
}
}
+4
View File
@@ -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": [