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
+158 -2
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",
{
@@ -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;