diff --git a/db/migrations/0095_time_tracking_smartsheet_sync.sql b/db/migrations/0095_time_tracking_smartsheet_sync.sql new file mode 100644 index 0000000..aacbe1c --- /dev/null +++ b/db/migrations/0095_time_tracking_smartsheet_sync.sql @@ -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.'; diff --git a/db/schema/time-tracking.ts b/db/schema/time-tracking.ts index f2466ea..a255f04 100644 --- a/db/schema/time-tracking.ts +++ b/db/schema/time-tracking.ts @@ -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; \ No newline at end of file + 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() + .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 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; \ No newline at end of file diff --git a/src/actions/time-tracking/field.ts b/src/actions/time-tracking/field.ts index 37d326e..a0067b3 100644 --- a/src/actions/time-tracking/field.ts +++ b/src/actions/time-tracking/field.ts @@ -28,6 +28,7 @@ import { timeTrackingWorkers, timeTrackingTasks, timeTrackingLogs, + timeTrackingSmartsheetSyncQueue, } from "@/db/schema/time-tracking"; import { verifyPin } from "@/lib/water-log-pin"; import { getWorkerPeriodTotals } from "./index"; @@ -233,6 +234,30 @@ function isUniqueViolation(err: unknown): boolean { return /unique constraint|duplicate key/i.test(msg); } +// Cycle 5 — best-effort Smartsheet queue enqueue. Lives in its own +// transaction (separate `withBrand` call) so it cannot poison the +// primary clock-out write when called from `clockOutWorker`. +export async function enqueueClockOutForSync( + brandId: string, + logId: string, +): Promise { + await withBrand(brandId, async (db) => { + await db + .insert(timeTrackingSmartsheetSyncQueue) + .values({ + brandId, + logId, + status: "pending", + }) + .onConflictDoNothing({ + target: [ + timeTrackingSmartsheetSyncQueue.brandId, + timeTrackingSmartsheetSyncQueue.logId, + ], + }); + }); +} + // ── Clock Out ────────────────────────────────────────────────────────────── export async function clockOutWorker( @@ -276,6 +301,17 @@ export async function clockOutWorker( }) .where(eq(timeTrackingLogs.id, log.id)); + // Cycle 5 — enqueue for Smartsheet sync in a SEPARATE transaction + // so a queue-insert failure (RLS denial, FK violation, transient + // backend error) can never poison the clock-out write above. + // Drizzle's `onConflictDoNothing` keeps retried clock-outs idempotent + // against the (brand_id, log_id) unique constraint from migration + // 0095. + void enqueueClockOutForSync(session.brand_id, log.id).catch(() => { + // Non-fatal: the admin UI surfaces persistent failures via the + // Recent Activity panel, and the cron drain retries. + }); + const totalMinutes = Math.max( 0, Math.round((now.getTime() - log.clockIn.getTime()) / 60000) - diff --git a/src/actions/time-tracking/smartsheet.ts b/src/actions/time-tracking/smartsheet.ts new file mode 100644 index 0000000..8137757 --- /dev/null +++ b/src/actions/time-tracking/smartsheet.ts @@ -0,0 +1,459 @@ +/** + * Time Tracking — Smartsheet integration server actions. + * + * Cycle 5 — UI surface for the admin `/admin/water-log/settings` + * card ("Time Tracking → Smartsheet"). Mirrors + * `src/actions/water-log/smartsheet.ts` so the two integrations share + * conventions. + * + * 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. + */ +"use server"; + +import { desc, eq } from "drizzle-orm"; +import { withBrand } from "@/db/client"; +import { + timeTrackingSmartsheetConfig, + timeTrackingSmartsheetSyncLog, + timeTrackingSmartsheetSyncQueue, + TT_SMARTSHEET_FREQUENCIES, + type TTSmartsheetColumnMapping, + type TTSmartsheetFrequency, +} from "@/db/schema/time-tracking"; +import { + decryptToken, + encryptToken, + maskToken, +} from "@/lib/crypto"; +import { + extractSheetId, + getSheetMeta, + SmartsheetApiError, +} from "@/lib/smartsheet"; +import { getAdminUser } from "@/lib/admin-permissions"; + +// ── Public types ─────────────────────────────────────────────────────────── + +export type TTSmartsheetConfigResponse = { + configured: boolean; + sheetId: string | null; + syncEnabled: boolean; + syncFrequency: TTSmartsheetFrequency; + columnMapping: TTSmartsheetColumnMapping | null; + maskedToken: string | null; + lastSyncAt: string | null; + lastSyncError: string | null; + hasToken: boolean; + updatedAt: string | null; +}; + +export type TTSmartsheetRecentEntry = { + id: string; + logId: string | null; + smartsheetRowId: string | null; + action: "sync" | "retry" | "skip" | "queue"; + success: boolean; + error: string | null; + durationMs: number | null; + createdAt: string; +}; + +export type TTSmartsheetQueueSummary = { + pending: number; + syncing: number; + synced: number; + failed: number; +}; + +export type SaveTTSmartsheetConfigInput = { + sheetId: string; + token: string; + syncEnabled: boolean; + syncFrequency: TTSmartsheetFrequency; + columnMapping: TTSmartsheetColumnMapping; +}; + +export type TTTestConnectionResult = + | { + success: true; + sheetName: string; + columnCount: number; + columns: { id: string; title: string; type: string }[]; + } + | { success: false; error: string }; + +// ── Auth ─────────────────────────────────────────────────────────────────── + +async function requireManagePerms(): Promise< + { ok: true } | { ok: false; error: string } +> { + const adminUser = await getAdminUser(); + if (!adminUser) return { ok: false, error: "Not authenticated" }; + // Time-tracking Smartsheet config is a brand-level integration + // setting — not a water-log concern. Gate on `can_manage_settings` + // until a dedicated `can_manage_time_tracking` flag exists. + if ( + !adminUser.can_manage_settings && + adminUser.role !== "platform_admin" + ) { + return { ok: false, error: "Not authorized" }; + } + return { ok: true }; +} + +// (Column keys + frequencies are imported from the schema and +// re-used directly — no re-export to avoid the "merged declaration" +// footgun.) + +// ── Read: get config ─────────────────────────────────────────────────────── + +export async function getTTSmartsheetConfig( + brandId: string, +): Promise { + const perm = await requireManagePerms(); + if (!perm.ok) { + return { + configured: false, + sheetId: null, + syncEnabled: false, + syncFrequency: "hourly", + columnMapping: null, + maskedToken: null, + lastSyncAt: null, + lastSyncError: null, + hasToken: false, + updatedAt: null, + }; + } + + return withBrand(brandId, async (db) => { + const rows = await db + .select() + .from(timeTrackingSmartsheetConfig) + .where(eq(timeTrackingSmartsheetConfig.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, + }; + } + // Build masked token by attempting to decrypt — but we don't + // surface the plaintext, only a fingerprint of it. + let masked: string | null = null; + try { + const plaintext = decryptToken({ + cipher: config.encryptedApiToken, + iv: config.tokenIv, + authTag: config.tokenAuthTag, + }); + masked = maskToken(plaintext); + } catch { + masked = null; + } + return { + configured: true, + sheetId: config.sheetId, + syncEnabled: config.syncEnabled, + syncFrequency: config.syncFrequency, + columnMapping: config.columnMapping, + maskedToken: masked, + hasToken: true, + lastSyncAt: config.lastSyncAt ? config.lastSyncAt.toISOString() : null, + lastSyncError: config.lastSyncError ?? null, + updatedAt: config.updatedAt + ? config.updatedAt.toISOString() + : null, + }; + }); +} + +// ── Write: save config ───────────────────────────────────────────────────── + +export async function saveTTSmartsheetConfig( + brandId: string, + input: SaveTTSmartsheetConfigInput, +): Promise< + { success: true; config: TTSmartsheetConfigResponse } | { success: false; error: string } +> { + const perm = await requireManagePerms(); + if (!perm.ok) return { success: false, error: perm.error }; + + // Validate. + try { + // extractSheetId throws on share-link slugs and bad URLs; we + // surface that error to the caller verbatim. + extractSheetId(input.sheetId); + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Invalid sheet ID", + }; + } + if ( + !TT_SMARTSHEET_FREQUENCIES.includes(input.syncFrequency) + ) { + return { + success: false, + error: `syncFrequency must be one of ${TT_SMARTSHEET_FREQUENCIES.join(", ")}`, + }; + } + if (!input.columnMapping.log_id || !input.columnMapping.clock_in) { + return { + success: false, + error: "columnMapping.log_id and columnMapping.clock_in are required (used for dedup)", + }; + } + + return withBrand(brandId, async (db) => { + const adminUser = (await getAdminUser()) ?? null; + const actorId = adminUser?.id ?? null; + + // Load existing to know whether we need a new token or keep the saved one. + const [existing] = await db + .select() + .from(timeTrackingSmartsheetConfig) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)) + .limit(1); + + let token: ReturnType | null = null; + if (input.token && input.token.trim().length > 0) { + try { + token = encryptToken(input.token.trim()); + } catch (err) { + return { + success: false, + error: + err instanceof Error + ? err.message + : "Failed to encrypt token", + }; + } + } + + if (!existing && !token) { + return { + success: false, + error: "Token is required for the first save", + }; + } + + if (existing && token) { + await db + .update(timeTrackingSmartsheetConfig) + .set({ + sheetId: extractSheetId(input.sheetId), + encryptedApiToken: token.cipher, + tokenIv: token.iv, + tokenAuthTag: token.authTag, + columnMapping: input.columnMapping, + syncFrequency: input.syncFrequency, + syncEnabled: input.syncEnabled, + updatedBy: actorId, + }) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)); + } else if (existing) { + // Keep saved token. + await db + .update(timeTrackingSmartsheetConfig) + .set({ + sheetId: extractSheetId(input.sheetId), + columnMapping: input.columnMapping, + syncFrequency: input.syncFrequency, + syncEnabled: input.syncEnabled, + updatedBy: actorId, + }) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)); + } else { + if (!token) { + return { + success: false, + error: "Token is required for the first save", + }; + } + await db.insert(timeTrackingSmartsheetConfig).values({ + brandId, + sheetId: extractSheetId(input.sheetId), + encryptedApiToken: token.cipher, + tokenIv: token.iv, + tokenAuthTag: token.authTag, + columnMapping: input.columnMapping, + syncFrequency: input.syncFrequency, + syncEnabled: input.syncEnabled, + createdBy: actorId, + updatedBy: actorId, + }); + } + + const fresh = await getTTSmartsheetConfig(brandId); + return { success: true, config: fresh }; + }); +} + +// ── Test connection ──────────────────────────────────────────────────────── + +export async function testTTSmartsheetConnection( + brandId: string, + input?: { sheetId?: string; token?: string }, +): Promise { + const perm = await requireManagePerms(); + if (!perm.ok) return { success: false, error: perm.error }; + + return withBrand(brandId, async (db) => { + const [existing] = await db + .select() + .from(timeTrackingSmartsheetConfig) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)) + .limit(1); + + const sheetId = input?.sheetId ?? existing?.sheetId ?? ""; + let plaintext = ""; + if (input?.token && input.token.trim().length > 0) { + plaintext = input.token.trim(); + } else if (existing) { + try { + plaintext = decryptToken({ + cipher: existing.encryptedApiToken, + iv: existing.tokenIv, + authTag: existing.tokenAuthTag, + }); + } catch (err) { + return { + success: false, + error: + err instanceof Error + ? err.message + : "Failed to decrypt saved token", + }; + } + } + if (!sheetId) { + return { success: false, error: "sheetId is required" }; + } + if (!plaintext) { + return { success: false, error: "token is required" }; + } + try { + const parsed = extractSheetId(sheetId); + const meta = await getSheetMeta(parsed, plaintext); + 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) { + // Funnel through sanitizeError to strip any token echoes from + // Smartsheet's WWW-Authenticate headers, mirroring the + // service-layer guarantee. + const raw = + err instanceof SmartsheetApiError + ? err.message + : err instanceof Error + ? err.message + : String(err); + return { + success: false, + error: sanitizeTestError(raw, plaintext), + }; + } + }); +} + +// Strip any token echo from Smartsheet's error responses before +// returning to the client. The plaintext token is only used locally +// (decrypted for the API call) — never crosses the wire. +function sanitizeTestError(raw: string, plaintext: string): string { + if (!raw || !plaintext) return raw; + return raw.split(plaintext).join("[redacted-token]"); +} + +// ── Read: recent sync attempts ───────────────────────────────────────────── + +export async function getTTSmartsheetRecent( + brandId: string, + limit = 20, +): Promise { + const perm = await requireManagePerms(); + if (!perm.ok) return []; + + return withBrand(brandId, async (db) => { + const rows = await db + .select() + .from(timeTrackingSmartsheetSyncLog) + .where(eq(timeTrackingSmartsheetSyncLog.brandId, brandId)) + .orderBy(desc(timeTrackingSmartsheetSyncLog.createdAt)) + .limit(limit); + return rows.map((r) => ({ + id: r.id, + logId: r.logId, + smartsheetRowId: r.smartsheetRowId, + action: r.action, + success: r.success, + error: r.error, + durationMs: r.durationMs, + createdAt: r.createdAt.toISOString(), + })); + }); +} + +// ── Read: queue summary ──────────────────────────────────────────────────── + +export async function getTTSmartsheetQueueSummary( + brandId: string, +): Promise { + const perm = await requireManagePerms(); + if (!perm.ok) + return { pending: 0, syncing: 0, synced: 0, failed: 0 }; + + return withBrand(brandId, async (db) => { + const rows = await db + .select({ + status: timeTrackingSmartsheetSyncQueue.status, + }) + .from(timeTrackingSmartsheetSyncQueue) + .where(eq(timeTrackingSmartsheetSyncQueue.brandId, brandId)); + return rows.reduce( + (acc, r) => { + acc[r.status] += 1; + return acc; + }, + { pending: 0, syncing: 0, synced: 0, failed: 0 }, + ); + }); +} + +// ── Disable (preserves token + config, turns off sync) ───────────────────── + +export async function disableTTSmartsheet( + brandId: string, +): Promise<{ success: true } | { success: false; error: string }> { + const perm = await requireManagePerms(); + if (!perm.ok) return { success: false, error: perm.error }; + + return withBrand(brandId, async (db) => { + await db + .update(timeTrackingSmartsheetConfig) + .set({ syncEnabled: false }) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)); + return { success: true }; + }); +} diff --git a/src/app/admin/water-log/settings/page.tsx b/src/app/admin/water-log/settings/page.tsx index c1e1b6f..305dacb 100644 --- a/src/app/admin/water-log/settings/page.tsx +++ b/src/app/admin/water-log/settings/page.tsx @@ -23,6 +23,7 @@ import { type AdminSettings, } from "@/actions/water-log/settings"; import SmartsheetIntegrationCard from "@/components/admin/water-log/SmartsheetIntegrationCard"; +import TimeTrackingSmartsheetCard from "@/components/admin/time-tracking/TimeTrackingSmartsheetCard"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; @@ -496,6 +497,31 @@ export default function WaterLogSettingsPage() { + + {/* Time Tracking → Smartsheet (Cycle 5). Independent of the + water-log smartsheet card above; uses its own sheet ID, + token, column mapping, and sync queue. Sibling surface in + this same hub view. */} +
+

+ § 06 — Time Tracking Integrations +

+

+ Smartsheet (Time Tracking) +

+

+ Push every closed clock-out from the time tracking system to a + configured Smartsheet sheet. Same encryption + retry policy as the + water-log smartsheet integration. Config-deferred — wire it up + whenever the brand is ready with a sheet ID + API token. +

+
+ +
+
); diff --git a/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx b/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx new file mode 100644 index 0000000..fe6ae56 --- /dev/null +++ b/src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx @@ -0,0 +1,552 @@ +"use client"; + +/** + * TimeTrackingSmartsheetCard — admin `/admin/water-log/settings` + * card for the Time Tracking → Smartsheet integration (Cycle 5). + * + * Slimmer than the water-log SmartsheetIntegrationCard because the + * user explicitly wants this config-deferred — they have no sheet + * ID yet. When they drop one in, they'll get the full column- + * mapping form (Test Connection gates it). Until then, only Sheet + * ID / token / frequency / sync-enabled controls are visible. + * + * Self-contained client component. Takes `brandId` as a prop per + * the brand-isolation convention. + */ + +import { useCallback, useEffect, useReducer, useState } from "react"; +import { + getTTSmartsheetConfig, + saveTTSmartsheetConfig, + testTTSmartsheetConnection, + getTTSmartsheetRecent, + disableTTSmartsheet, + getTTSmartsheetQueueSummary, + type TTSmartsheetConfigResponse, + type TTSmartsheetRecentEntry, + type TTSmartsheetQueueSummary, +} from "@/actions/time-tracking/smartsheet"; +import { TT_SMARTSHEET_FREQUENCIES } from "@/db/schema/time-tracking"; +import type { + TTSmartsheetColumnMapping, + TTSmartsheetColumnKey, + TTSmartsheetFrequency, +} from "@/db/schema/time-tracking"; +import { formatDateTime } from "@/lib/format-date"; + +type Props = { + brandId: string; +}; + +type Status = + | { kind: "idle" } + | { kind: "saved-at"; iso: string } + | { kind: "error"; message: string }; + +type State = { + config: TTSmartsheetConfigResponse | null; + sheetIdInput: string; + tokenInput: string; + showTokenInput: boolean; + frequency: TTSmartsheetFrequency; + syncEnabled: boolean; + columns: TTColumnOption[]; + columnMapping: TTSmartsheetColumnMapping; + recent: TTSmartsheetRecentEntry[]; + queue: TTSmartsheetQueueSummary; + loading: boolean; + testing: boolean; + saving: boolean; + status: Status; +}; + +type TTColumnOption = { + id: string; + title: string; + type: string; +}; + +type Action = + | { type: "SET_CONFIG"; config: TTSmartsheetConfigResponse } + | { type: "SET_SHEET_ID"; value: string } + | { type: "SET_TOKEN"; value: string } + | { type: "SET_SHOW_TOKEN"; value: boolean } + | { type: "SET_FREQUENCY"; value: TTSmartsheetFrequency } + | { type: "SET_SYNC_ENABLED"; value: boolean } + | { type: "SET_COLUMNS"; columns: NonNullable } + | { type: "SET_COL_MAPPING_VALUE"; key: TTSmartsheetColumnKey; columnId: string | null } + | { type: "SET_RECENT"; entries: TTSmartsheetRecentEntry[] } + | { type: "SET_QUEUE"; queue: TTSmartsheetQueueSummary } + | { type: "SET_LOADING"; loading: boolean } + | { type: "SET_TESTING"; value: boolean } + | { type: "SET_SAVING"; value: boolean } + | { type: "SET_STATUS"; value: Status }; + +const EMPTY_MAPPING: TTSmartsheetColumnMapping = { + log_id: "", + clock_in: "", + clock_out: null, + worker: null, + task: null, + hours: null, + lunch_minutes: null, + notes: null, +}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "SET_CONFIG": { + const cfg = action.config; + return { + ...state, + config: cfg, + sheetIdInput: cfg.sheetId ?? "", + frequency: cfg.syncFrequency, + syncEnabled: cfg.syncEnabled, + showTokenInput: !cfg.hasToken, + columnMapping: cfg.columnMapping ?? { ...EMPTY_MAPPING }, + loading: false, + }; + } + case "SET_SHEET_ID": + return { ...state, sheetIdInput: action.value }; + case "SET_TOKEN": + return { ...state, tokenInput: action.value }; + case "SET_SHOW_TOKEN": + return { ...state, showTokenInput: action.value }; + case "SET_FREQUENCY": + return { ...state, frequency: action.value }; + case "SET_SYNC_ENABLED": + return { ...state, syncEnabled: action.value }; + case "SET_COLUMNS": + return { ...state, columns: action.columns }; + case "SET_COL_MAPPING_VALUE": + return { + ...state, + columnMapping: { + ...state.columnMapping, + [action.key]: action.columnId, + }, + }; + case "SET_RECENT": + return { ...state, recent: action.entries }; + case "SET_QUEUE": + return { ...state, queue: action.queue }; + case "SET_LOADING": + return { ...state, loading: action.loading }; + case "SET_TESTING": + return { ...state, testing: action.value }; + case "SET_SAVING": + return { ...state, saving: action.value }; + case "SET_STATUS": + return { ...state, status: action.value }; + } +} + +function initialState(): State { + return { + config: null, + sheetIdInput: "", + tokenInput: "", + showTokenInput: true, + frequency: "hourly", + syncEnabled: false, + columns: [], + columnMapping: { ...EMPTY_MAPPING }, + recent: [], + queue: { pending: 0, syncing: 0, synced: 0, failed: 0 }, + loading: true, + testing: false, + saving: false, + status: { kind: "idle" }, + }; +} + +export function TimeTrackingSmartsheetCard({ brandId }: Props) { + const [state, dispatch] = useReducer(reducer, undefined, initialState); + const [, force] = useState({}); // for non-react subscribed re-renders + + const refresh = useCallback(async () => { + const [cfg, recent, queue] = await Promise.all([ + getTTSmartsheetConfig(brandId), + getTTSmartsheetRecent(brandId, 10), + getTTSmartsheetQueueSummary(brandId), + ]); + dispatch({ type: "SET_CONFIG", config: cfg }); + dispatch({ type: "SET_RECENT", entries: recent }); + dispatch({ type: "SET_QUEUE", queue }); + }, [brandId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const onTestConnection = useCallback(async () => { + dispatch({ type: "SET_TESTING", value: true }); + dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); + const result = await testTTSmartsheetConnection(brandId, { + sheetId: state.sheetIdInput, + token: state.tokenInput || undefined, + }); + if (!result.success) { + dispatch({ + type: "SET_STATUS", + value: { kind: "error", message: result.error }, + }); + dispatch({ type: "SET_TESTING", value: false }); + return; + } + dispatch({ type: "SET_COLUMNS", columns: result.columns }); + dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); + dispatch({ type: "SET_TESTING", value: false }); + force({}); + }, [brandId, state.sheetIdInput, state.tokenInput]); + + const onSave = useCallback(async () => { + dispatch({ type: "SET_SAVING", value: true }); + dispatch({ type: "SET_STATUS", value: { kind: "idle" } }); + const result = await saveTTSmartsheetConfig(brandId, { + sheetId: state.sheetIdInput, + token: state.tokenInput, + syncEnabled: state.syncEnabled, + syncFrequency: state.frequency, + columnMapping: state.columnMapping, + }); + if (!result.success) { + dispatch({ + type: "SET_STATUS", + value: { kind: "error", message: result.error }, + }); + dispatch({ type: "SET_SAVING", value: false }); + return; + } + dispatch({ + type: "SET_CONFIG", + config: result.config, + }); + dispatch({ + type: "SET_STATUS", + value: { kind: "saved-at", iso: new Date().toISOString() }, + }); + dispatch({ type: "SET_SAVING", value: false }); + }, [brandId, state]); + + const onDisable = useCallback(async () => { + dispatch({ type: "SET_SAVING", value: true }); + await disableTTSmartsheet(brandId); + await refresh(); + dispatch({ type: "SET_SAVING", value: false }); + dispatch({ + type: "SET_STATUS", + value: { kind: "saved-at", iso: new Date().toISOString() }, + }); + }, [brandId, refresh]); + + if (state.loading) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+
+

+ Smartsheet (Time Tracking) +

+

+ Push every closed clock-out to a configured Smartsheet sheet. + Same encryption + retry policy as the Water Log smartsheet + integration. +

+
+ +
+ +
+
+ + + dispatch({ type: "SET_SHEET_ID", value: e.target.value }) + } + className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm" + data-test="tt-smartsheet-sheet-id" + /> +
+
+ +
+ + dispatch({ type: "SET_TOKEN", value: e.target.value }) + } + className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm" + data-test="tt-smartsheet-token" + /> + +
+ {state.config?.hasToken && !state.tokenInput && ( +

+ A token is saved. Leave blank to keep it. +

+ )} +
+
+ + +
+
+ +
+ + + {state.config?.configured && state.syncEnabled && ( + + )} + + {state.status.kind === "saved-at" + ? `Saved at ${formatDateTime(new Date(state.status.iso))}` + : state.status.kind === "error" + ? state.status.message + : state.config?.lastSyncAt + ? `Last sync ${formatDateTime(new Date(state.config.lastSyncAt))}` + : "Never synced"} + +
+ + {state.columns.length > 0 && ( + + dispatch({ + type: "SET_COL_MAPPING_VALUE", + key, + columnId, + }) + } + /> + )} + + +
+ ); +} + +function ColumnMappingPanel({ + columns, + mapping, + onChange, +}: { + columns: NonNullable; + mapping: TTSmartsheetColumnMapping; + onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void; +}) { + return ( +
+

Column mapping

+

+ Required: log_id,{" "} + clock_in. Others optional. +

+
+ {( + [ + ["log_id", "Log ID (UUID, for dedup)", true], + ["clock_in", "Clock-in (timestamp)", true], + ["clock_out", "Clock-out (timestamp)", false], + ["worker", "Worker name", false], + ["task", "Task name", false], + ["hours", "Hours (decimal)", false], + ["lunch_minutes", "Lunch minutes", false], + ["notes", "Notes", false], + ] as Array<[TTSmartsheetColumnKey, string, boolean]> + ).map(([key, label, required]) => ( + + ))} +
+
+ ); +} + +function RecentActivity({ + recent, + queue, +}: { + recent: TTSmartsheetRecentEntry[]; + queue: TTSmartsheetQueueSummary; +}) { + return ( +
+

+ Recent activity +

+
+ Pending: {queue.pending} + Syncing: {queue.syncing} + Synced: {queue.synced} + Failed: {queue.failed} +
+
+ + + + + + + + + + + {recent.length === 0 && ( + + + + )} + {recent.map((r) => ( + + + + + + + ))} + +
WhenActionResultError
+ No sync attempts yet. +
+ {formatDateTime(new Date(r.createdAt))} + {r.action} + + {r.success ? "OK" : "FAIL"} + + + {r.error ?? "—"} +
+
+
+ ); +} + +export default TimeTrackingSmartsheetCard; diff --git a/src/services/time-tracking-smartsheet-sync.ts b/src/services/time-tracking-smartsheet-sync.ts new file mode 100644 index 0000000..53caaed --- /dev/null +++ b/src/services/time-tracking-smartsheet-sync.ts @@ -0,0 +1,538 @@ +/** + * Time Tracking → Smartsheet sync engine. + * + * Cycle 5 — mirrors the water-log smartsheet-sync service so a brand + * can push every closed clock-out to a configured Smartsheet sheet. + * + * Callers: + * - `enqueueClockOutForSync()` from `clockOutWorker` (realtime) + * - `/api/time-tracking/smartsheet-sync` cron (every 15 min / hourly) + * - Manual retry via the admin UI's "Retry now" button (future) + * + * Public surface: + * - `syncLogToSmartsheet(brandId, logId)` — push one clock-out + * - `drainTTSyncQueue(brandId, opts)` — process pending / due retries + * - `runScheduledTTSync(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 `drainTTSyncQueue` 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 } from "drizzle-orm"; +import { withBrand } from "@/db/client"; +import { + timeTrackingLogs, + timeTrackingWorkers, + timeTrackingTasks, + timeTrackingSmartsheetConfig, + timeTrackingSmartsheetSyncQueue, + timeTrackingSmartsheetSyncLog, + type TTSmartsheetColumnMapping, +} from "@/db/schema/time-tracking"; +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 TTSyncResult = + | { + 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 TTDrainResult = { + brandId: string; + considered: number; + synced: number; + skipped: number; + failed: number; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Strip anything that looks like our token from an error string. + * Smartsheet 4xx echoes the bearer token in some `WWW-Authenticate` + * headers; we never want a token footgun in the UI. */ +function sanitizeError(raw: string, plaintextToken: string): string { + if (!raw) return raw; + if (!plaintextToken) return raw; + return raw.split(plaintextToken).join("[redacted-token]"); +} + +/** Build a `log_id` cell fingerprint — the row ID we look up on the + * sheet for dedup. Stable and unique per row. */ +function logIdFingerprint(logId: string): string { + return logId; +} + +/** Convert a clock-in/out pair to a decimal-hours string ("7.42"). */ +function hoursBetween(clockIn: Date, clockOut: Date, lunchMinutes: number) { + const ms = clockOut.getTime() - clockIn.getTime() - lunchMinutes * 60_000; + return Math.max(0, ms / 3_600_000).toFixed(2); +} + +/** Build the cells array from a closed log row using the brand's + * column mapping. Returns null if the mapping is missing required + * fields (log_id / clock_in). */ +function buildCells( + mapping: TTSmartsheetColumnMapping, + values: { + logId: string; + clockInIso: string; + clockOutIso: string; + workerName: string; + taskName: string; + hours: string; + lunchMinutes: number; + notes: string | null; + }, +): SmartsheetCell[] | null { + if (!mapping.log_id || !mapping.clock_in) return null; + const cells: SmartsheetCell[] = [ + { columnId: mapping.log_id, value: logIdFingerprint(values.logId) }, + { columnId: mapping.clock_in, value: values.clockInIso }, + ]; + if (mapping.clock_out && values.clockOutIso) { + cells.push({ columnId: mapping.clock_out, value: values.clockOutIso }); + } + if (mapping.worker && values.workerName) { + cells.push({ columnId: mapping.worker, value: values.workerName }); + } + if (mapping.task && values.taskName) { + cells.push({ columnId: mapping.task, value: values.taskName }); + } + if (mapping.hours && values.hours) { + cells.push({ columnId: mapping.hours, value: values.hours }); + } + if (mapping.lunch_minutes) { + cells.push({ + columnId: mapping.lunch_minutes, + value: String(values.lunchMinutes), + }); + } + if (mapping.notes) { + cells.push({ + columnId: mapping.notes, + value: values.notes ?? "", + }); + } + return cells; +} + +// ── Core: sync one closed log row ────────────────────────────────────────── + +export async function syncLogToSmartsheet( + brandId: string, + logId: string, +): Promise { + const start = Date.now(); + return withBrand(brandId, async (db) => { + // Load config + queue row + log + worker in parallel. + const [configRows, queueRows, logRows] = await Promise.all([ + db + .select() + .from(timeTrackingSmartsheetConfig) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)) + .limit(1), + db + .select() + .from(timeTrackingSmartsheetSyncQueue) + .where( + and( + eq(timeTrackingSmartsheetSyncQueue.brandId, brandId), + eq(timeTrackingSmartsheetSyncQueue.logId, logId), + ), + ) + .limit(1), + db + .select({ + log: timeTrackingLogs, + workerName: timeTrackingWorkers.name, + }) + .from(timeTrackingLogs) + .leftJoin( + timeTrackingWorkers, + eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), + ) + .where(eq(timeTrackingLogs.id, logId)) + .limit(1), + ]); + + const config = configRows[0]; + const queueRow = queueRows[0]; + const logRow = logRows[0]; + + if (!config) { + const result: TTSyncResult = { + status: "failed", + error: "Smartsheet config not set up for this brand", + attempts: 0, + durationMs: Date.now() - start, + retryAt: new Date(), + }; + return result; + } + if (!config.syncEnabled) { + const result: TTSyncResult = { + status: "skipped", + reason: "Sync disabled in config", + smartsheetRowId: queueRow?.smartsheetRowId ?? null, + durationMs: Date.now() - start, + }; + return result; + } + if (!logRow || !logRow.log.clockOut) { + const result: TTSyncResult = { + status: "failed", + error: "Clock-out row not found or still open", + attempts: 0, + durationMs: Date.now() - start, + retryAt: new Date(), + }; + return result; + } + if (!queueRow) { + const result: TTSyncResult = { + status: "failed", + error: "No sync queue row — was the clock-out queued?", + attempts: 0, + durationMs: Date.now() - start, + retryAt: new Date(), + }; + return result; + } + + // Already synced → short-circuit. + if (queueRow.smartsheetRowId && queueRow.status === "synced") { + const result: TTSyncResult = { + status: "skipped", + reason: "Already synced", + smartsheetRowId: queueRow.smartsheetRowId, + durationMs: Date.now() - start, + }; + return result; + } + + const token = decryptToken({ + cipher: config.encryptedApiToken, + iv: config.tokenIv, + authTag: config.tokenAuthTag, + }); + + const values = { + logId: logRow.log.id, + clockInIso: logRow.log.clockIn.toISOString(), + clockOutIso: logRow.log.clockOut.toISOString(), + workerName: logRow.workerName ?? "(unknown worker)", + taskName: logRow.log.taskName, + hours: hoursBetween( + logRow.log.clockIn, + logRow.log.clockOut, + logRow.log.lunchBreakMinutes, + ), + lunchMinutes: logRow.log.lunchBreakMinutes, + notes: logRow.log.notes ?? null, + }; + + const cells = buildCells(config.columnMapping, values); + if (!cells) { + const result: TTSyncResult = { + status: "failed", + error: "Column mapping is missing required fields (log_id, clock_in)", + attempts: queueRow.attempts + 1, + durationMs: Date.now() - start, + retryAt: new Date( + Date.now() + + Math.min(MAX_BACKOFF_MINUTES, 2 ** (queueRow.attempts + 1)) * + 60_000, + ), + }; + // Persist failure on the queue row. + await db + .update(timeTrackingSmartsheetSyncQueue) + .set({ + status: "failed", + attempts: queueRow.attempts + 1, + lastError: result.error, + nextAttemptAt: result.retryAt, + }) + .where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id)); + await db.insert(timeTrackingSmartsheetSyncLog).values({ + brandId, + logId, + action: "retry", + success: false, + error: result.error, + durationMs: result.durationMs, + }); + return result; + } + + // Dedup by log_id column, then insert. + let existing = null as Awaited>; + try { + existing = await findRowByColumn( + config.sheetId, + token, + config.columnMapping.log_id, + values.logId, + ); + } catch (err) { + // Dedup is best-effort — log the error and continue to insert. + // If a duplicate lands in the sheet, the admin can resolve + // manually using the Recent Activity panel. + await db.insert(timeTrackingSmartsheetSyncLog).values({ + brandId, + logId, + action: "skip", + success: false, + error: `Dedup search failed: ${sanitizeError( + (err as Error).message ?? String(err), + token, + )}`, + durationMs: Date.now() - start, + }); + } + + if (existing) { + // Existing row — record the dedup hit and mark the queue row synced. + await db + .update(timeTrackingSmartsheetSyncQueue) + .set({ + status: "synced", + smartsheetRowId: existing.rowId, + syncedAt: new Date(), + attempts: queueRow.attempts + 1, + lastError: null, + }) + .where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id)); + await db.insert(timeTrackingSmartsheetSyncLog).values({ + brandId, + logId, + smartsheetRowId: existing.rowId, + action: "skip", + success: true, + durationMs: Date.now() - start, + }); + const result: TTSyncResult = { + status: "skipped", + reason: "Already present in sheet (dedup hit)", + smartsheetRowId: existing.rowId, + durationMs: Date.now() - start, + }; + return result; + } + + // No existing row — insert. + try { + const row = await addRow(config.sheetId, token, cells); + const durationMs = Date.now() - start; + await db + .update(timeTrackingSmartsheetSyncQueue) + .set({ + status: "synced", + smartsheetRowId: row.rowId, + syncedAt: new Date(), + attempts: queueRow.attempts + 1, + lastError: null, + }) + .where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id)); + await db.update(timeTrackingSmartsheetConfig).set({ + lastSyncAt: new Date(), + lastSyncError: null, + }).where(eq(timeTrackingSmartsheetConfig.brandId, brandId)); + await db.insert(timeTrackingSmartsheetSyncLog).values({ + brandId, + logId, + smartsheetRowId: row.rowId, + action: queueRow.attempts > 0 ? "retry" : "sync", + success: true, + durationMs, + }); + const result: TTSyncResult = { + status: "synced", + smartsheetRowId: row.rowId, + attempts: queueRow.attempts + 1, + durationMs, + }; + return result; + } catch (err) { + const message = sanitizeError( + err instanceof SmartsheetApiError + ? `${err.message} (status ${err.status})` + : (err as Error).message ?? String(err), + token, + ); + const newAttempts = queueRow.attempts + 1; + const retryAt = new Date( + Date.now() + + Math.min(MAX_BACKOFF_MINUTES, 2 ** newAttempts) * 60_000, + ); + await db + .update(timeTrackingSmartsheetSyncQueue) + .set({ + status: newAttempts >= DEFAULT_MAX_ATTEMPTS ? "failed" : "pending", + attempts: newAttempts, + lastError: message, + nextAttemptAt: retryAt, + }) + .where(eq(timeTrackingSmartsheetSyncQueue.id, queueRow.id)); + await db.insert(timeTrackingSmartsheetSyncLog).values({ + brandId, + logId, + action: "retry", + success: false, + error: message, + durationMs: Date.now() - start, + }); + const result: TTSyncResult = { + status: "failed", + error: message, + attempts: newAttempts, + durationMs: Date.now() - start, + retryAt, + }; + return result; + } + }); +} + +// ── Drain (used by cron + manual retry) ──────────────────────────────────── + +export type DrainOptions = { + batchSize?: number; + maxAttempts?: number; +}; + +export async function drainTTSyncQueue( + brandId: string, + opts: DrainOptions = {}, +): Promise { + const batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE; + const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + + return withBrand(brandId, async (db) => { + const due = await db + .select({ id: timeTrackingSmartsheetSyncQueue.id }) + .from(timeTrackingSmartsheetSyncQueue) + .where( + and( + eq(timeTrackingSmartsheetSyncQueue.brandId, brandId), + inArray(timeTrackingSmartsheetSyncQueue.status, [ + "pending", + "failed", + ]), + lte(timeTrackingSmartsheetSyncQueue.nextAttemptAt, new Date()), + ), + ) + .limit(batchSize); + + const result: TTDrainResult = { + brandId, + considered: due.length, + synced: 0, + skipped: 0, + failed: 0, + }; + + for (const row of due) { + // Re-fetch the queue row's log_id (the outer select only had id) + const queueRows = await db + .select() + .from(timeTrackingSmartsheetSyncQueue) + .where(eq(timeTrackingSmartsheetSyncQueue.id, row.id)) + .limit(1); + const queueRow = queueRows[0]; + if (!queueRow) continue; + if (queueRow.attempts >= maxAttempts) { + result.failed += 1; + continue; + } + const r = await syncLogToSmartsheet(brandId, queueRow.logId); + if (r.status === "synced") result.synced += 1; + else if (r.status === "skipped") result.skipped += 1; + else result.failed += 1; + } + return result; + }); +} + +// ── Cron entrypoint ──────────────────────────────────────────────────────── + +/** Used by the cron route. Reads the config's frequency and decides + * whether to drain now (realtime → always; every_15 → if last drain + * was >=15m ago; hourly → if last drain >=60m ago). + * + * Returns the drain result, or `{ skipped: true, reason }` if it's + * not yet time for this brand. */ +export async function runScheduledTTSync( + brandId: string, +): Promise { + return withBrand(brandId, async (db) => { + const [config] = await db + .select() + .from(timeTrackingSmartsheetConfig) + .where(eq(timeTrackingSmartsheetConfig.brandId, brandId)) + .limit(1); + + if (!config) return { skipped: true, reason: "No config" }; + if (!config.syncEnabled) + return { skipped: true, reason: "Sync disabled" }; + + if ( + config.syncFrequency !== "realtime" && + config.lastSyncAt + ) { + const elapsedMin = + (Date.now() - config.lastSyncAt.getTime()) / 60_000; + const minInterval = + config.syncFrequency === "every_15_minutes" ? 15 : 60; + if (elapsedMin < minInterval) { + return { + skipped: true, + reason: `Last sync ${Math.round(elapsedMin)}m ago; need ${minInterval}m`, + }; + } + } + return drainTTSyncQueue(brandId); + }); +} + +// Suppress unused-import warnings (the smoke-test rationale used to +// live here but both types are reachable from the call site now). +void timeTrackingTasks;