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:
Nora
2026-07-03 18:41:36 -06:00
parent dfd6b63888
commit b793cd6955
7 changed files with 1959 additions and 2 deletions
@@ -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.';
+157 -1
View File
@@ -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",
{
@@ -159,3 +172,146 @@ export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog =
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;
+36
View File
@@ -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<void> {
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) -
+459
View File
@@ -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<TTSmartsheetConfigResponse> {
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<typeof encryptToken> | 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<TTTestConnectionResult> {
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<TTSmartsheetRecentEntry[]> {
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<TTSmartsheetQueueSummary> {
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<TTSmartsheetQueueSummary>(
(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 };
});
}
+26
View File
@@ -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() {
<SmartsheetIntegrationCard brandId={TUXEDO_BRAND_ID} />
</div>
</div>
{/* 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. */}
<div className="mt-12 border-t border-[#d4d9d3] pt-10">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">
§ 06 Time Tracking Integrations
</p>
<h2
className="mt-2 text-2xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Smartsheet (Time Tracking)
</h2>
<p className="mt-2 text-sm text-[#5a5d5a]">
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.
</p>
<div className="mt-6">
<TimeTrackingSmartsheetCard brandId={TUXEDO_BRAND_ID} />
</div>
</div>
</div>
</div>
);
@@ -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<State["columns"]> }
| { 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 (
<div className="rounded-2xl border border-[#e6dfd1] bg-white p-6">
<div className="h-5 w-40 bg-[#fdfaf2] animate-pulse rounded" />
<div className="mt-3 h-4 w-72 bg-[#fdfaf2] animate-pulse rounded" />
</div>
);
}
return (
<div
className="rounded-2xl border border-[#e6dfd1] bg-white p-6 shadow-sm"
data-test="tt-smartsheet-card"
>
<div className="mb-4 flex items-start justify-between">
<div>
<h2 className="text-xl font-bold text-[#1a4d2e]">
Smartsheet (Time Tracking)
</h2>
<p className="mt-1 text-sm text-[rgba(60,60,67,0.65)]">
Push every closed clock-out to a configured Smartsheet sheet.
Same encryption + retry policy as the Water Log smartsheet
integration.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#1a4d2e]">
<input
type="checkbox"
className="h-4 w-4 rounded border-[#1a4d2e] accent-[#1a4d2e]"
checked={state.syncEnabled}
onChange={(e) =>
dispatch({
type: "SET_SYNC_ENABLED",
value: e.target.checked,
})
}
data-test="tt-smartsheet-enabled"
/>
Enabled
</label>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Sheet ID or URL
</label>
<input
type="text"
placeholder="e.g. 8309876543210123"
value={state.sheetIdInput}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
API token
</label>
<div className="flex items-center gap-2">
<input
type={state.showTokenInput ? "text" : "password"}
placeholder={
state.config?.hasToken && !state.showTokenInput
? state.config.maskedToken ?? "••••••••"
: "paste new token here"
}
value={state.tokenInput}
onChange={(e) =>
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"
/>
<button
type="button"
className="rounded-md border border-[#e6dfd1] bg-white px-2 py-1 text-xs font-semibold text-[rgba(60,60,67,0.65)] hover:bg-[#fdfaf2]"
onClick={() =>
dispatch({
type: "SET_SHOW_TOKEN",
value: !state.showTokenInput,
})
}
>
{state.showTokenInput ? "Hide" : "Show"}
</button>
</div>
{state.config?.hasToken && !state.tokenInput && (
<p className="mt-1 text-xs text-[rgba(60,60,67,0.55)]">
A token is saved. Leave blank to keep it.
</p>
)}
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-[rgba(60,60,67,0.65)]">
Frequency
</label>
<select
value={state.frequency}
onChange={(e) =>
dispatch({
type: "SET_FREQUENCY",
value: e.target.value as TTSmartsheetFrequency,
})
}
className="w-full rounded-md border border-[#e6dfd1] bg-[#fdfaf2] px-3 py-2 text-sm"
data-test="tt-smartsheet-frequency"
>
{TT_SMARTSHEET_FREQUENCIES.map((f) => (
<option key={f} value={f}>
{f.replace(/_/g, " ")}
</option>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
disabled={state.testing || !state.sheetIdInput}
onClick={onTestConnection}
className="rounded-md border border-[#1a4d2e] bg-white px-3 py-1.5 text-sm font-semibold text-[#1a4d2e] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-test"
>
{state.testing ? "Testing…" : "Test Connection"}
</button>
<button
type="button"
disabled={state.saving}
onClick={onSave}
className="rounded-md bg-[#1a4d2e] px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-[#14432a] disabled:cursor-not-allowed disabled:opacity-50"
data-test="tt-smartsheet-save"
>
{state.saving ? "Saving…" : "Save"}
</button>
{state.config?.configured && state.syncEnabled && (
<button
type="button"
disabled={state.saving}
onClick={onDisable}
className="rounded-md border border-[#a4452b] bg-white px-3 py-1.5 text-sm font-semibold text-[#a4452b] hover:bg-[#fdfaf2] disabled:cursor-not-allowed disabled:opacity-50"
>
Disable
</button>
)}
<span className="ml-2 text-xs text-[rgba(60,60,67,0.55)]">
{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"}
</span>
</div>
{state.columns.length > 0 && (
<ColumnMappingPanel
columns={state.columns}
mapping={state.columnMapping}
onChange={(key, columnId) =>
dispatch({
type: "SET_COL_MAPPING_VALUE",
key,
columnId,
})
}
/>
)}
<RecentActivity recent={state.recent} queue={state.queue} />
</div>
);
}
function ColumnMappingPanel({
columns,
mapping,
onChange,
}: {
columns: NonNullable<State["columns"]>;
mapping: TTSmartsheetColumnMapping;
onChange: (key: TTSmartsheetColumnKey, columnId: string | null) => void;
}) {
return (
<div className="mt-6 rounded-xl border border-[#e6dfd1] bg-[#fdfaf2] p-4">
<h3 className="text-sm font-semibold text-[#1a4d2e]">Column mapping</h3>
<p className="mt-1 text-xs text-[rgba(60,60,67,0.65)]">
Required: <span className="font-mono">log_id</span>,{" "}
<span className="font-mono">clock_in</span>. Others optional.
</p>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{(
[
["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]) => (
<label key={key} className="block">
<span className="block text-xs font-medium text-[rgba(60,60,67,0.65)]">
{label}
{required && (
<span className="ml-1 text-[#a4452b]">*</span>
)}
</span>
<select
value={mapping[key] ?? ""}
onChange={(e) =>
onChange(
key,
e.target.value === "" ? null : e.target.value,
)
}
className="mt-1 w-full rounded-md border border-[#e6dfd1] bg-white px-2 py-1.5 text-sm"
>
<option value=""> not mapped </option>
{columns.map((c) => (
<option key={c.id} value={c.id}>
{c.title || `Column ${c.id}`}
</option>
))}
</select>
</label>
))}
</div>
</div>
);
}
function RecentActivity({
recent,
queue,
}: {
recent: TTSmartsheetRecentEntry[];
queue: TTSmartsheetQueueSummary;
}) {
return (
<div className="mt-6">
<h3 className="text-sm font-semibold text-[#1a4d2e]">
Recent activity
</h3>
<div className="mt-2 flex gap-3 text-xs text-[rgba(60,60,67,0.65)]">
<span>Pending: {queue.pending}</span>
<span>Syncing: {queue.syncing}</span>
<span>Synced: {queue.synced}</span>
<span>Failed: {queue.failed}</span>
</div>
<div className="mt-3 overflow-x-auto rounded-xl border border-[#e6dfd1]">
<table className="w-full text-left text-sm">
<thead className="bg-[#fdfaf2] text-xs uppercase tracking-wide text-[rgba(60,60,67,0.55)]">
<tr>
<th className="px-3 py-2">When</th>
<th className="px-3 py-2">Action</th>
<th className="px-3 py-2">Result</th>
<th className="px-3 py-2">Error</th>
</tr>
</thead>
<tbody>
{recent.length === 0 && (
<tr>
<td
className="px-3 py-3 text-center text-[rgba(60,60,67,0.55)]"
colSpan={4}
>
No sync attempts yet.
</td>
</tr>
)}
{recent.map((r) => (
<tr key={r.id} className="border-t border-[#f0e9d8]">
<td className="px-3 py-2 text-xs">
{formatDateTime(new Date(r.createdAt))}
</td>
<td className="px-3 py-2 text-xs">{r.action}</td>
<td className="px-3 py-2 text-xs">
<span
className={
r.success
? "inline-block rounded-full bg-[#1a4d2e]/10 px-2 py-0.5 text-[#1a4d2e]"
: "inline-block rounded-full bg-[#a4452b]/10 px-2 py-0.5 text-[#a4452b]"
}
>
{r.success ? "OK" : "FAIL"}
</span>
</td>
<td className="px-3 py-2 text-xs">
{r.error ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default TimeTrackingSmartsheetCard;
@@ -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<TTSyncResult> {
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<ReturnType<typeof findRowByColumn>>;
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<TTDrainResult> {
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<TTDrainResult | { skipped: true; reason: string }> {
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;