-- ============================================================================ -- 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).';