-- ============================================================================ -- 0095_time_tracking_smartsheet_sync.sql -- -- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring -- the water-log smartsheet pattern from migration 0093. Three tables: -- -- 1. time_tracking_smartsheet_config — one row per brand; encrypted -- token, sheet id, column -- mapping, frequency, enable -- flag, last-sync metadata -- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that -- needs syncing; tracks -- attempts + status for retry -- and observability -- 3. time_tracking_smartsheet_sync_log — append-only log of every -- sync attempt (success OR -- failure); backs the -- "Recent Activity" panel -- -- 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). -- -- Cycle 5 scope: -- This migration ONLY introduces schema + RLS. The actual sync -- service, server actions, and admin UI live in their own files. -- The brand will fill in sheet_id + token + column mapping when -- the customer is ready; everything works end-to-end once they do. -- ============================================================================ -- ── 1. Config table ──────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS time_tracking_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 time-tracking fields → Smartsheet column IDs. -- Shape: { log_id: string, clock_in: string, clock_out: string, -- worker: string, task: string, hours: string, -- lunch_minutes: string | null, notes: string | null } -- `log_id` and `clock_in` 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 time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config; CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at BEFORE UPDATE ON time_tracking_smartsheet_config FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- RLS: brand-scoped. ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config; CREATE POLICY tenant_isolation ON time_tracking_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 clock-out awaiting sync) ──────────────────── CREATE TABLE IF NOT EXISTS time_tracking_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 clock-out. ON DELETE CASCADE means deleting a log row -- also removes its queue row, preventing orphaned sync attempts. log_id UUID NOT NULL REFERENCES time_tracking_logs(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, CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id) ); -- Hot path: drain query scans `(brand_id, status, next_attempt_at)`. CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at); -- Recent-attempts view. CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC); ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue; CREATE POLICY tenant_isolation ON time_tracking_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 time_tracking_smartsheet_sync_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL, smartsheet_row_id TEXT, -- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue' 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 time_tracking_smartsheet_log_brand_recent_idx ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC); ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log; CREATE POLICY tenant_isolation ON time_tracking_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 log row (for dedup decisions) ───────── CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS SELECT DISTINCT ON (q.brand_id, q.log_id) q.brand_id, q.log_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 time_tracking_smartsheet_sync_queue q ORDER BY q.brand_id, q.log_id, q.created_at DESC; COMMENT ON TABLE time_tracking_smartsheet_config IS 'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.'; COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS 'One row per time_tracking_log awaiting / completed sync to Smartsheet.'; COMMENT ON TABLE time_tracking_smartsheet_sync_log IS 'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';