feat(time-tracking): cycle 5 — Smartsheet sync scaffold
Mirrors the water-log Smartsheet pattern (migration 0093 + src/services/smartsheet-sync.ts). End-to-end wiring is in place; the brand fills in sheet ID + token + column mapping whenever ready. DB: - 0095_time_tracking_smartsheet_sync.sql — config + sync_queue + sync_log tables with brand-scoped RLS. Unique constraint on (brand_id, log_id) keeps the queue idempotent against retries. Code: - db/schema/time-tracking.ts — adds TTSmartsheetFrequency, TT_SMARTSHEET_FREQUENCIES, TTSmartsheetColumnKey, TTSmartsheetColumnMapping, and the three pgTable defs. - src/services/time-tracking-smartsheet-sync.ts — syncLogToSmartsheet (dedup-by-log_id, token-echo sanitization, retry-with-backoff), drainTTSyncQueue, runScheduledTTSync. - src/actions/time-tracking/smartsheet.ts — full admin CRUD (getTTSmartsheetConfig, saveTTSmartsheetConfig, testTTSmartsheetConnection, getTTSmartsheetRecent, getTTSmartsheetQueueSummary, disableTTSmartsheet). - src/actions/time-tracking/field.ts — clockOutWorker calls the new enqueueClockOutForSync helper in its OWN transaction (so a queue- insert failure can never poison the clock-out commit; reviewer caught this in cycle 2's same-style bug pattern). - src/components/admin/time-tracking/TimeTrackingSmartsheetCard.tsx — slim, focused card: sheet ID + token + frequency + enable + Test Connection → column-mapping dropdowns + Recent Activity table. - src/app/admin/water-log/settings/page.tsx — §06 section renders the new card as a sibling of the existing water-log smartsheet card. Permission gate uses can_manage_settings + platform_admin (not can_manage_water_log) — feature is a brand-level integration setting, not water-log-specific. Out of scope / deferred: - /api/time-tracking/smartsheet-sync cron route. The trigger is optional: realtime mode syncs inline; 15-min/hourly modes need the cron. Add in a follow-up cycle when the cron infrastructure is revisited. - drag-and-drop column-mapping UX (current card uses plain dropdowns). - backfill flow (no equivalent of the water-log "backfill past N days" feature yet). - can_manage_time_tracking permission flag (uses can_manage_settings as the proxy; revisit once the admin_users schema gets a time-tracking-specific permission column).
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
-- ============================================================================
|
||||
-- 0095_time_tracking_smartsheet_sync.sql
|
||||
--
|
||||
-- Cycle 5 — Per-brand Smartsheet integration for Time Tracking, mirroring
|
||||
-- the water-log smartsheet pattern from migration 0093. Three tables:
|
||||
--
|
||||
-- 1. time_tracking_smartsheet_config — one row per brand; encrypted
|
||||
-- token, sheet id, column
|
||||
-- mapping, frequency, enable
|
||||
-- flag, last-sync metadata
|
||||
-- 2. time_tracking_smartsheet_sync_queue — one row per clock-out that
|
||||
-- needs syncing; tracks
|
||||
-- attempts + status for retry
|
||||
-- and observability
|
||||
-- 3. time_tracking_smartsheet_sync_log — append-only log of every
|
||||
-- sync attempt (success OR
|
||||
-- failure); backs the
|
||||
-- "Recent Activity" panel
|
||||
--
|
||||
-- Security:
|
||||
-- - API token is AES-256-GCM encrypted at rest using a key from
|
||||
-- SMARTSHEET_TOKEN_ENC_KEY. The token NEVER leaves the server in
|
||||
-- plaintext via any server action / API response.
|
||||
-- - All tables brand-scoped via the existing `app.current_brand_id`
|
||||
-- GUC + `current_brand_id()` function (see 0001_init.sql).
|
||||
--
|
||||
-- Cycle 5 scope:
|
||||
-- This migration ONLY introduces schema + RLS. The actual sync
|
||||
-- service, server actions, and admin UI live in their own files.
|
||||
-- The brand will fill in sheet_id + token + column mapping when
|
||||
-- the customer is ready; everything works end-to-end once they do.
|
||||
-- ============================================================================
|
||||
|
||||
-- ── 1. Config table ────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_config (
|
||||
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- The numeric Smartsheet sheet ID (not the share URL).
|
||||
-- UI accepts either form and parses to numeric here.
|
||||
sheet_id TEXT NOT NULL,
|
||||
|
||||
-- AES-256-GCM encrypted API token (stored as BYTEA so we can store
|
||||
-- raw bytes, not base64). Use the helpers in src/lib/crypto.ts.
|
||||
encrypted_api_token BYTEA NOT NULL,
|
||||
token_iv BYTEA NOT NULL, -- 12 random bytes per encrypt
|
||||
token_auth_tag BYTEA NOT NULL, -- 16-byte GCM auth tag
|
||||
|
||||
-- Maps our internal time-tracking fields → Smartsheet column IDs.
|
||||
-- Shape: { log_id: string, clock_in: string, clock_out: string,
|
||||
-- worker: string, task: string, hours: string,
|
||||
-- lunch_minutes: string | null, notes: string | null }
|
||||
-- `log_id` and `clock_in` are required (used for dedup).
|
||||
column_mapping JSONB NOT NULL,
|
||||
|
||||
-- 'realtime' | 'every_15_minutes' | 'hourly'
|
||||
sync_frequency TEXT NOT NULL DEFAULT 'hourly'
|
||||
CHECK (sync_frequency IN ('realtime','every_15_minutes','hourly')),
|
||||
|
||||
sync_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Set after every sync attempt (success or failure).
|
||||
last_sync_at TIMESTAMPTZ,
|
||||
last_sync_error TEXT,
|
||||
|
||||
-- User IDs from `neon_auth.user`; stored as text so we don't
|
||||
-- require a FK to a specific auth backend.
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Auto-bump updated_at on row UPDATE.
|
||||
DROP TRIGGER IF EXISTS time_tracking_smartsheet_config_set_updated_at ON time_tracking_smartsheet_config;
|
||||
CREATE TRIGGER time_tracking_smartsheet_config_set_updated_at
|
||||
BEFORE UPDATE ON time_tracking_smartsheet_config
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- RLS: brand-scoped.
|
||||
ALTER TABLE time_tracking_smartsheet_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_config;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_config FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 2. Sync queue (one row per clock-out awaiting sync) ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_queue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- FK to the clock-out. ON DELETE CASCADE means deleting a log row
|
||||
-- also removes its queue row, preventing orphaned sync attempts.
|
||||
log_id UUID NOT NULL REFERENCES time_tracking_logs(id) ON DELETE CASCADE,
|
||||
|
||||
-- The Smartsheet row ID returned from a successful sync.
|
||||
-- NULL = not yet synced.
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'pending' | 'syncing' | 'synced' | 'failed'
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','syncing','synced','failed')),
|
||||
|
||||
-- Bumped on every attempt (success or failure). Caps at 5 (after
|
||||
-- which the row stays 'failed' permanently; admin must re-enable).
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Last failure reason (sanitized — token never appears here).
|
||||
last_error TEXT,
|
||||
|
||||
-- When the next retry is allowed. Set to NOW() initially; pushed
|
||||
-- out by exponential backoff on failure (2^attempts minutes, cap 1h).
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
synced_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT time_tracking_smartsheet_queue_log_unique UNIQUE (brand_id, log_id)
|
||||
);
|
||||
|
||||
-- Hot path: drain query scans `(brand_id, status, next_attempt_at)`.
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_status_idx
|
||||
ON time_tracking_smartsheet_sync_queue (brand_id, status, next_attempt_at);
|
||||
|
||||
-- Recent-attempts view.
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_queue_brand_created_idx
|
||||
ON time_tracking_smartsheet_sync_queue (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE time_tracking_smartsheet_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_queue;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_queue FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 3. Sync log (append-only, backs Recent Activity UI) ────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS time_tracking_smartsheet_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
log_id UUID REFERENCES time_tracking_logs(id) ON DELETE SET NULL,
|
||||
smartsheet_row_id TEXT,
|
||||
|
||||
-- 'sync' | 'retry' | 'skip' (dedup hit) | 'queue'
|
||||
action TEXT NOT NULL
|
||||
CHECK (action IN ('sync','retry','skip','queue')),
|
||||
|
||||
success BOOLEAN NOT NULL,
|
||||
error TEXT,
|
||||
duration_ms INT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS time_tracking_smartsheet_log_brand_recent_idx
|
||||
ON time_tracking_smartsheet_sync_log (brand_id, created_at DESC);
|
||||
|
||||
ALTER TABLE time_tracking_smartsheet_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_smartsheet_sync_log;
|
||||
CREATE POLICY tenant_isolation ON time_tracking_smartsheet_sync_log FOR ALL
|
||||
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
|
||||
-- ── 4. Helper view: latest sync per log row (for dedup decisions) ─────────
|
||||
|
||||
CREATE OR REPLACE VIEW time_tracking_smartsheet_latest_sync AS
|
||||
SELECT DISTINCT ON (q.brand_id, q.log_id)
|
||||
q.brand_id,
|
||||
q.log_id,
|
||||
q.smartsheet_row_id,
|
||||
q.status,
|
||||
q.attempts,
|
||||
q.last_error,
|
||||
q.next_attempt_at,
|
||||
q.created_at AS queued_at,
|
||||
q.synced_at
|
||||
FROM time_tracking_smartsheet_sync_queue q
|
||||
ORDER BY q.brand_id, q.log_id, q.created_at DESC;
|
||||
|
||||
COMMENT ON TABLE time_tracking_smartsheet_config IS
|
||||
'Per-brand Smartsheet integration config for Time Tracking. Token is AES-256-GCM encrypted.';
|
||||
COMMENT ON TABLE time_tracking_smartsheet_sync_queue IS
|
||||
'One row per time_tracking_log awaiting / completed sync to Smartsheet.';
|
||||
COMMENT ON TABLE time_tracking_smartsheet_sync_log IS
|
||||
'Append-only audit of Time Tracking ↔ Smartsheet sync attempts.';
|
||||
+158
-2
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Time Tracking. Source: `db/migrations/0001_init.sql`.
|
||||
* Time Tracking. Source: `db/migrations/0001_init.sql` and the
|
||||
* Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`).
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -10,9 +11,21 @@ import {
|
||||
boolean,
|
||||
timestamp,
|
||||
index,
|
||||
jsonb,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
// drizzle-orm/pg-core does not export a `bytea` shorthand the way
|
||||
// many typed helpers do; declare it once here so the schema can use
|
||||
// raw BYTEA columns for encrypted tokens. Mirrors the helper in
|
||||
// `db/schema/water-log.ts` for the water-log smartsheet config.
|
||||
const bytea = customType<{ data: Buffer; default: false }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
|
||||
export const timeTrackingSettings = pgTable(
|
||||
"time_tracking_settings",
|
||||
{
|
||||
@@ -158,4 +171,147 @@ export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
|
||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
typeof timeTrackingNotificationLog.$inferSelect;
|
||||
|
||||
// ── Smartsheet sync (Cycle 5) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Allowed sync frequencies. Mirrors the water-log smartsheet
|
||||
* pattern (see `db/schema/water-log.ts` SMARTSHEET_FREQUENCIES).
|
||||
*/
|
||||
export const TT_SMARTSHEET_FREQUENCIES = [
|
||||
"realtime",
|
||||
"every_15_minutes",
|
||||
"hourly",
|
||||
] as const;
|
||||
export type TTSmartsheetFrequency = (typeof TT_SMARTSHEET_FREQUENCIES)[number];
|
||||
|
||||
/**
|
||||
* Time-tracking fields a brand can map to their Smartsheet columns.
|
||||
* `log_id` and `clock_in` are required (used for dedup).
|
||||
*/
|
||||
export type TTSmartsheetColumnKey =
|
||||
| "log_id"
|
||||
| "clock_in"
|
||||
| "clock_out"
|
||||
| "worker"
|
||||
| "task"
|
||||
| "hours"
|
||||
| "lunch_minutes"
|
||||
| "notes";
|
||||
export const TT_SMARTSHEET_COLUMN_KEYS: readonly TTSmartsheetColumnKey[] = [
|
||||
"log_id",
|
||||
"clock_in",
|
||||
"clock_out",
|
||||
"worker",
|
||||
"task",
|
||||
"hours",
|
||||
"lunch_minutes",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Shape stored in `time_tracking_smartsheet_config.column_mapping`.
|
||||
* Required: log_id + clock_in (for dedup). All others nullable.
|
||||
*/
|
||||
export type TTSmartsheetColumnMapping = {
|
||||
log_id: string;
|
||||
clock_in: string;
|
||||
clock_out: string | null;
|
||||
worker: string | null;
|
||||
task: string | null;
|
||||
hours: string | null;
|
||||
lunch_minutes: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export const timeTrackingSmartsheetConfig = pgTable(
|
||||
"time_tracking_smartsheet_config",
|
||||
{
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
sheetId: text("sheet_id").notNull(),
|
||||
encryptedApiToken: bytea("encrypted_api_token").notNull(),
|
||||
tokenIv: bytea("token_iv").notNull(),
|
||||
tokenAuthTag: bytea("token_auth_tag").notNull(),
|
||||
columnMapping: jsonb("column_mapping")
|
||||
.$type<TTSmartsheetColumnMapping>()
|
||||
.notNull(),
|
||||
syncFrequency: text("sync_frequency")
|
||||
.$type<TTSmartsheetFrequency>()
|
||||
.notNull()
|
||||
.default("hourly"),
|
||||
syncEnabled: boolean("sync_enabled").notNull().default(false),
|
||||
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
|
||||
lastSyncError: text("last_sync_error"),
|
||||
createdBy: text("created_by"),
|
||||
updatedBy: text("updated_by"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingSmartsheetSyncQueue = pgTable(
|
||||
"time_tracking_smartsheet_sync_queue",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id")
|
||||
.notNull()
|
||||
.references(() => timeTrackingLogs.id, { onDelete: "cascade" }),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
status: text("status", {
|
||||
enum: ["pending", "syncing", "synced", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
lastError: text("last_error"),
|
||||
nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
syncedAt: timestamp("synced_at", { withTimezone: true }),
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingSmartsheetSyncLog = pgTable(
|
||||
"time_tracking_smartsheet_sync_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
logId: uuid("log_id").references(() => timeTrackingLogs.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
smartsheetRowId: text("smartsheet_row_id"),
|
||||
action: text("action", {
|
||||
enum: ["sync", "retry", "skip", "queue"],
|
||||
}).notNull(),
|
||||
success: boolean("success").notNull(),
|
||||
error: text("error"),
|
||||
durationMs: integer("duration_ms"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export type TimeTrackingSmartsheetConfig =
|
||||
typeof timeTrackingSmartsheetConfig.$inferSelect;
|
||||
export type TimeTrackingSmartsheetConfigInsert =
|
||||
typeof timeTrackingSmartsheetConfig.$inferInsert;
|
||||
export type TimeTrackingSmartsheetSyncQueue =
|
||||
typeof timeTrackingSmartsheetSyncQueue.$inferSelect;
|
||||
export type TimeTrackingSmartsheetSyncLog =
|
||||
typeof timeTrackingSmartsheetSyncLog.$inferSelect;
|
||||
@@ -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) -
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user