feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 + src/services/smartsheet-sync.ts). End-to-end wiring is in place; the brand fills in sheet ID + token + column mapping whenever ready. DB: - 0095_time_tracking_smartsheet_sync.sql — config + sync_queue + sync_log tables with brand-scoped RLS. Unique constraint on (brand_id, log_id) keeps the queue idempotent against retries. Code: - db/schema/time-tracking.ts — adds TTSmartsheetFrequency, TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey, TTSmartsheetColumnMapping, and the three pgTable defs. - src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet (dedup-by-log_id, token-echo sanitization, retry-with-backoff), drainTTSyncQueue, runScheduledTTSync. - src/actions/time-tracking/smartsheet.ts — full admin CRUD (getTTSmartsheetConfig, saveTTSmartsheetConfig, testTTSmartsheetConnection, getTTSmartsheetRecent, getTTSmartsheetQueueSummary, disableTTSmartsheet). - src/actions/time-tracking/field.ts — clockOutWorker calls the new enqueueClockOutForSync helper in its OWN transaction (so a queue- insert failure can never poison the clock-out commit; reviewer caught this in cycle 2's same-style bug pattern). - src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx — slim, focused card: sheet ID + token + frequency + enable + Test Connection → column-mapping dropdowns + Recent Activity table. - src/app/admin/water-log/settings/page.tsx — §06 section renders the new card as a sibling of the existing water-log smartsheet card. Permission gate uses can_manage_settings + platform_admin (not can_manage_water_log) — feature is a brand-level integration setting, not water-log-specific. Out of scope / deferred: - /api/time-tracking/smartsheet-sync cron route. The trigger is optional: realtime mode syncs inline; 15-min/hourly modes need the cron. Add in a follow-up cycle when the cron infrastructure is revisited. - drag-and-drop column-mapping UX (current card uses plain dropdowns). - backfill flow (no equivalent of the water-log "backfill past N days" feature yet). - can_manage_time_tracking permission flag (uses can_manage_settings as the proxy; revisit once the admin_users schema gets a time-tracking-specific permission column).
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
-- ============================================================================
|
||||
-- 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.';
|
||||
+158
-2
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Time Tracking. Source: `db/migrations/0001_init.sql`.
|
||||
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
|
||||
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -10,9 +11,21 @@ import {
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
jsonb,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
// drizzle-orm/pg-core does not export a `bytea` shorthand the way
|
||||
// many typed helpers do; declare it once here so the schema can use
|
||||
// raw BYTEA columns for encrypted tokens. Mirrors the helper in
|
||||
// `db/schema/water-log.ts` for the water-log smartsheet config.
|
||||
const bytea = customType<{ data: Buffer; default: false }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
|
||||
export const timeTrackingSettings = pgTable(
|
||||
"time_tracking_settings",
|
||||
{
|
||||
@@ -158,4 +171,147 @@ export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
|
||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
|
||||
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Allowed sync frequencies. Mirrors the water-log smartsheet
|
||||
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
|
||||
*/
|
||||
export const TT_SMARTSHEET_FREQUENCIES = [
|
||||
"realtime",
|
||||
"every_15_minutes",
|
||||
"hourly",
|
||||
] as const;
|
||||
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
|
||||
|
||||
/**
|
||||
* Time-tracking fields a brand can map to their Smartsheet columns.
|
||||
* `log_id` and `clock_in` are required (used for dedup).
|
||||
*/
|
||||
export type TTSmartsheetColumnKey =
|
||||
| "log_id"
|
||||
| "clock_in"
|
||||
| "clock_out"
|
||||
| "worker"
|
||||
| "task"
|
||||
| "hours"
|
||||
| "lunch_minutes"
|
||||
| "notes";
|
||||
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
|
||||
"log_id",
|
||||
"clock_in",
|
||||
"clock_out",
|
||||
"worker",
|
||||
"task",
|
||||
"hours",
|
||||
"lunch_minutes",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
|
||||
* Required: log_id + clock_in (for dedup). All others nullable.
|
||||
*/
|
||||
export type TTSmartsheetColumnMapping = {
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
worker: string | null;
|
||||
task: string | null;
|
||||
hours: string | null;
|
||||
lunch_minutes: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export const timeTrackingSmartsheetConfig = pgTable(
|
||||
"time_tracking_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<TTSmartsheetColumnMapping>()
|
||||
.notNull(),
|
||||
syncFrequency: text("sync_frequency")
|
||||
.$type<TTSmartsheetFrequency>()
|
||||
.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 const timeTrackingSmartsheetSyncQueue = pgTable(
|
||||
"time_tracking_smartsheet_sync_queue",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id")
|
||||
.notNull()
|
||||
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
status: text("status", {
|
||||
enum: ["pending", "syncing", "synced", "failed"],
|
||||
})
|
||||
.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 }),
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingSmartsheetSyncLog = pgTable(
|
||||
"time_tracking_smartsheet_sync_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
action: text("action", {
|
||||
enum: ["sync", "retry", "skip", "queue"],
|
||||
}).notNull(),
|
||||
success: boolean("success").notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export type TimeTrackingSmartsheetConfig =
|
||||
typeof timeTrackingSmartsheetConfig.$inferSelect;
|
||||
export type TimeTrackingSmartsheetConfigInsert =
|
||||
typeof timeTrackingSmartsheetConfig.$inferInsert;
|
||||
export type TimeTrackingSmartsheetSyncQueue =
|
||||
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
|
||||
export type TimeTrackingSmartsheetSyncLog =
|
||||
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
|
||||
Reference in New Issue
Block a user