diff --git a/.env.example b/.env.example index 4679ec6..ab0df97 100644 --- a/.env.example +++ b/.env.example @@ -79,3 +79,18 @@ CRON_SECRET=replace-me-with-a-random-string # the server actions — but you can override it with: # TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de # 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 diff --git a/MEMORY.md b/MEMORY.md index 1987e36..1169197 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -625,3 +625,90 @@ Push `2d55791` fixes two issues that broke the "Start Docker stack" step: - `origin/main` (GitHub) — for backup visibility - 36 commits ahead of `origin/main` as of this entry + +## 2026-07: Smartsheet sync added to Water Log module + +Per-brand Smartsheet integration that pushes new `water_log_entries` +rows to a configured sheet. Config UI lives in +`/admin/water-log/settings` as a self-contained card. + +**Key files added:** +- `db/migrations/0093_water_smartsheet_sync.sql` — 3 tables: + `water_smartsheet_config` (one row per brand; encrypted token + + sheet ID + column mapping + frequency + enable flag + last-sync + metadata), `water_smartsheet_sync_queue` (one row per entry + awaiting sync; status / attempts / backoff), `water_smartsheet_sync_log` + (append-only Recent Activity feed for the UI). RLS via the existing + `current_brand_id()` / `is_platform_admin()` pattern. +- `db/schema/water-log.ts` — appended the three tables + types + (`SmartsheetFrequency`, `SmartsheetColumnKey`, `SmartsheetColumnMapping`). + Uses a small custom `bytea` Drizzle type since `drizzle-orm/pg-core` + doesn't export one in this version. +- `src/lib/crypto.ts` — AES-256-GCM helpers (`encryptToken`, + `decryptToken`, `maskToken`). Reads `SMARTSHEET_TOKEN_ENC_KEY`. + Throws on missing/wrong-size key — no insecure default. +- `src/lib/smartsheet.ts` — REST API wrapper (no SDK — `smartsheet` + npm has Node 22 compat issues). Exposes `extractSheetId`, + `getSheetMeta`, `addRow`, `findRowByColumn`, and a typed + `SmartsheetApiError` class. +- `src/services/smartsheet-sync.ts` — pure sync engine: + `syncEntryToSmartsheet`, `drainSyncQueue`, `runScheduledSync`. + Sequential per-row processing to stay well under Smartsheet's + 300 req/min cap. Exponential backoff capped at 60 min, max 5 + attempts before the row stays `failed` permanently. +- `src/actions/water-log/smartsheet.ts` — `"use server"` actions: + `getSmartsheetConfig`, `saveSmartsheetConfig`, `deleteSmartsheetConfig`, + `testSmartsheetConnection`, `triggerSyncForEntry`, `listSmartsheetSyncLog`. + Token never returned in plaintext — UI gets `maskedToken` only. +- `src/components/admin/water-log/SmartsheetIntegrationCard.tsx` — + client component using `useReducer`. Sections: Enable toggle, + Sheet URL/ID + API token + Test Connection, sync frequency radio + group, column mapping dropdowns (populated after Test), Recent + Activity panel. +- `src/app/api/water-log/smartsheet-sync/route.ts` — POST cron route. + Bearer-auth via `SMARTSHEET_CRON_SECRET` (falls back to `CRON_SECRET`). + Optional `{brandId}` body to drain one brand. +- `vercel.json` — added `*/15 * * * *` cron entry pointing at the + route above. Single entry covers both 15-min and hourly frequencies + (route filters by per-brand `sync_frequency`). + +**Hooks into existing code:** +- `src/actions/water-log/field.ts::submitWaterEntry` — calls + `triggerSyncForEntry(brandId, entryId)` in a fire-and-forget void + after the entry insert. Sync failures NEVER bubble to the field + worker. +- `src/app/admin/water-log/settings/page.tsx` — mounted the new card + as a new "§ 05 — Integrations" section after the existing admin- + PIN settings. Card takes `brandId` as a prop (CLAUDE.md "Brand ID + Threading"). + +**Env vars (`.env.example`):** +- `SMARTSHEET_TOKEN_ENC_KEY` — REQUIRED, 32 random bytes base64. + Generate: `openssl rand -base64 32`. +- `SMARTSHEET_CRON_SECRET` — optional bearer for the cron route. +- `SMARTSHEET_SYNC_TIMEOUT_MS` — optional, default 8000. + +**Migration push (Vercel deploy + manual):** +```bash +npm run migrate:one 93 # pushes 0093_water_smartsheet_sync.sql +``` +Then provision the encryption key in Vercel dashboard: +`SMARTSHEET_TOKEN_ENC_KEY` (paste the `openssl rand -base64 32` output). + +**Rollback story:** +1. Remove the cron entry from `vercel.json`. +2. SQL rollback: `DROP TABLE water_smartsheet_sync_log;` + `DROP TABLE water_smartsheet_sync_queue;` `DROP TABLE water_smartsheet_config;` +3. Revert the changes to `field.ts` and `settings/page.tsx`. +4. `git revert` the merge commit. + +**Known sharp edges:** +- No key rotation support — changing `SMARTSHEET_TOKEN_ENC_KEY` makes + existing tokens unreadable; admins must re-enter. Document if/when + Phase 2 adds a `key_version` column. +- Share-URL Smartsheet sheets often have opaque slugs (not numeric); + if "Test Connection" 404s with a slug URL, ask the brand admin for + the numeric sheet ID from `File → Properties` in Smartsheet. +- `findRowByColumn` is O(n) over up to 2,500 rows; fine for typical + water-log sheets but switches to search-API dependency if any + brand ever exceeds ~10k entries/year. diff --git a/db/migrations/0093_water_smartsheet_sync.sql b/db/migrations/0093_water_smartsheet_sync.sql new file mode 100644 index 0000000..034eefb --- /dev/null +++ b/db/migrations/0093_water_smartsheet_sync.sql @@ -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).'; \ No newline at end of file diff --git a/db/schema/water-log.ts b/db/schema/water-log.ts index 9be0ac6..ca1bed5 100644 --- a/db/schema/water-log.ts +++ b/db/schema/water-log.ts @@ -24,11 +24,19 @@ import { index, uniqueIndex, integer, - check, + customType, } from "drizzle-orm/pg-core"; import { brands } 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( "water_headgates", { @@ -255,3 +263,177 @@ export type WaterAlertLog = typeof waterAlertLog.$inferSelect; export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect; export type WaterAdminSession = typeof waterAdminSessions.$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() + .notNull(), + syncFrequency: text("sync_frequency") + .$type() + .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() + .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().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; diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index f2c6961..9a4541a 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -41,6 +41,7 @@ import { setSessionCookie, } from "@/lib/water-log/session-server"; 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 // `@/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 }; }); } diff --git a/src/actions/water-log/smartsheet.ts b/src/actions/water-log/smartsheet.ts new file mode 100644 index 0000000..2989731 --- /dev/null +++ b/src/actions/water-log/smartsheet.ts @@ -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 { + 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 { + 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 = { + 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 { + 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 { + 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; + 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, + }, + }; +} \ No newline at end of file diff --git a/src/app/admin/water-log/settings/page.tsx b/src/app/admin/water-log/settings/page.tsx index 1d9128c..d7d6b7f 100644 --- a/src/app/admin/water-log/settings/page.tsx +++ b/src/app/admin/water-log/settings/page.tsx @@ -22,6 +22,7 @@ import { regenerateAdminPin, type AdminSettings, } from "@/actions/water-log/settings"; +import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; @@ -412,6 +413,28 @@ export default function WaterLogSettingsPage() { {saving ? "Saving…" : "Save Settings"} + + {/* Smartsheet Integration — independent of the Admin Portal toggle above. + Self-contained card with its own save / test / activity surface. */} +
+

+ § 05 — Integrations +

+

+ Smartsheet +

+

+ 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. +

+
+ +
+
); diff --git a/src/app/api/water-log/smartsheet-sync/route.ts b/src/app/api/water-log/smartsheet-sync/route.ts new file mode 100644 index 0000000..c1ed44a --- /dev/null +++ b/src/app/api/water-log/smartsheet-sync/route.ts @@ -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 { + 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); +} \ No newline at end of file diff --git a/src/components/admin/water-log/SmartsheetIntegrationCard.tsx b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx new file mode 100644 index 0000000..92c6ddc --- /dev/null +++ b/src/components/admin/water-log/SmartsheetIntegrationCard.tsx @@ -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 = { + 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 = { + 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 ( + +

Reading saved configuration.

+
+ ); + } + + const noTokenYet = !state.savedConfig?.hasToken && state.tokenInput.length === 0; + + return ( +
+ {state.message && ( +
+ {state.message.text} +
+ )} + + + dispatch({ type: "SET_ENABLED", value: v })} + /> + + + + {/* Sheet */} +
+ + + 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" + /> +

+ Paste the share URL from your browser address bar, or the + numeric sheet ID. If a share URL doesn't work, use the + numeric ID from File → Properties in Smartsheet. +

+
+ + {/* Token */} +
+ +
+ + 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" + /> + +
+

+ Generate a token at{" "} + + app.smartsheet.com + {" "} + → Account →{" "} + Personal Settings →{" "} + API Access. The token is + encrypted with AES-256-GCM at rest. +

+
+ + {/* Test Connection */} +
+ + +
+
+ + +
+ {(Object.keys(FREQUENCY_LABELS) as SmartsheetFrequency[]).map((f) => ( + + ))} +
+
+ + + {state.availableColumns.length === 0 ? ( +

+ Run Test Connection above to load the sheet's + columns. You can also paste column IDs manually below. +

+ ) : ( +

+ Loaded {state.availableColumns.length} columns from sheet{" "} + + {state.test.state === "ok" ? state.test.sheetName : "—"} + + . Pick from the dropdowns below. +

+ )} + +
+ {(Object.keys(COLUMN_LABELS) as Array).map( + (key) => { + const required = REQUIRED_COLUMNS.includes(key); + const value = state.mapping[key]; + const valueAsString = value ?? ""; + return ( +
+ + {state.availableColumns.length > 0 ? ( + + ) : ( + + 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]" + /> + )} +
+ ); + }, + )} +
+
+ + + {state.loadingLog ? ( +

Loading…

+ ) : state.recentLog.length === 0 ? ( +

+ No sync attempts yet. Save the config and submit a water log + entry from /water. +

+ ) : ( +
    + {state.recentLog.map((log) => ( +
  • + + {log.success ? "✓" : "✗"} + +
    +
    + + {log.action} + + + {formatDateTime(log.createdAt)} + + {log.durationMs != null && ( + + {log.durationMs}ms + + )} +
    + {log.error && ( +

    {log.error}

    + )} +
    +
  • + ))} +
+ )} +
+ + + + {noTokenYet && ( +

+ Paste an API token above to enable Save. +

+ )} +
+ ); +} + +// ── Subcomponents (mirror the parent settings page style) ────────────────── + +function Card({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ); +} + +function ToggleRow({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+

{label}

+ {description && ( +

{description}

+ )} +
+ +
+ ); +} + +function TestResultPanel({ test }: { test: TestState }) { + if (test.state === "idle") return null; + if (test.state === "loading") { + return Testing…; + } + if (test.state === "ok") { + return ( +
+

+ ✓ Connected to “{test.sheetName}” ({test.columnCount} columns) +

+
+ ); + } + return ( +
+

✗ {test.message}

+
+ ); +} \ No newline at end of file diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..3a4f659 --- /dev/null +++ b/src/lib/crypto.ts @@ -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)}`; +} \ No newline at end of file diff --git a/src/lib/smartsheet.ts b/src/lib/smartsheet.ts new file mode 100644 index 0000000..705fdec --- /dev/null +++ b/src/lib/smartsheet.ts @@ -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 ` 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( + method: "GET" | "POST", + path: string, + token: string, + body?: unknown, +): Promise { + 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 { + 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( + "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( + "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( + "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; +} \ No newline at end of file diff --git a/src/services/smartsheet-sync.ts b/src/services/smartsheet-sync.ts new file mode 100644 index 0000000..45b5b2d --- /dev/null +++ b/src/services/smartsheet-sync.ts @@ -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 { + 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 { + 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 { + return drainSyncQueue(brandId); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +type DbClient = Parameters[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 { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/vercel.json b/vercel.json index 07720d9..0cc645b 100644 --- a/vercel.json +++ b/vercel.json @@ -32,6 +32,10 @@ { "path": "/api/cron/send-scheduled", "schedule": "0 9 * * *" + }, + { + "path": "/api/water-log/smartsheet-sync", + "schedule": "*/15 * * * *" } ], "headers": [