/** * Time Tracking. Source: `db/migrations/0001_init.sql` and the * Cycle-5 Smartsheet scaffold (`db/migrations/0095_*.sql`). * * Cycle 7: the encrypted token columns on * `time_tracking_smartsheet_config` moved to `smartsheet_workspace` * (see `db/schema/smartsheet-workspace.ts`). */ import { pgTable, uuid, text, numeric, integer, boolean, timestamp, index, jsonb, } from "drizzle-orm/pg-core"; import { brands } from "./brands"; export const timeTrackingSettings = pgTable( "time_tracking_settings", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .unique() .references(() => brands.id, { onDelete: "cascade" }), payPeriodStartDay: integer("pay_period_start_day").notNull().default(0), payPeriodLengthDays: integer("pay_period_length_days") .notNull() .default(7), dailyOvertimeThreshold: numeric("daily_overtime_threshold", { precision: 5, scale: 2, }).notNull().default("8.0"), weeklyOvertimeThreshold: numeric("weekly_overtime_threshold", { precision: 5, scale: 2, }).notNull().default("40.0"), overtimeMultiplier: numeric("overtime_multiplier", { precision: 3, scale: 2, }).notNull().default("1.50"), overtimeNotifications: boolean("overtime_notifications") .notNull() .default(true), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export const timeTrackingWorkers = pgTable( "time_tracking_workers", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), role: text("role", { enum: ["worker", "time_admin"] }) .notNull() .default("worker"), lang: text("lang").notNull().default("en"), pin: text("pin").notNull(), active: boolean("active").notNull().default(true), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId), }), ); export const timeTrackingTasks = pgTable( "time_tracking_tasks", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), nameEs: text("name_es"), unit: text("unit", { enum: ["hours", "pieces", "units"] }) .notNull() .default("hours"), sortOrder: integer("sort_order").notNull().default(0), active: boolean("active").notNull().default(true), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("time_tracking_tasks_brand_idx").on(t.brandId), }), ); export const timeTrackingLogs = pgTable( "time_tracking_logs", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), workerId: uuid("worker_id") .notNull() .references(() => timeTrackingWorkers.id, { onDelete: "cascade" }), taskId: uuid("task_id").references(() => timeTrackingTasks.id, { onDelete: "set null", }), taskName: text("task_name").notNull(), clockIn: timestamp("clock_in", { withTimezone: true }).notNull(), clockOut: timestamp("clock_out", { withTimezone: true }), lunchBreakMinutes: integer("lunch_break_minutes").notNull().default(0), notes: text("notes"), submittedVia: text("submitted_via", { enum: ["manual", "field", "import"], }).notNull().default("manual"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId), workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId), clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn), }), ); export const timeTrackingNotificationLog = pgTable( "time_tracking_notification_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), workerId: uuid("worker_id").references( () => timeTrackingWorkers.id, { onDelete: "set null" }, ), notificationType: text("notification_type").notNull(), recipient: text("recipient").notNull(), subject: text("subject"), body: text("body").notNull(), status: text("status", { enum: ["pending", "sent", "failed"], }).notNull().default("pending"), sentAt: timestamp("sent_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect; export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect; 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(), // Cycle 7: encrypted token columns moved to smartsheet_workspace. // See migration 0096_smartsheet_workbook_hub.sql. columnMapping: jsonb("column_mapping") .$type() .notNull(), syncFrequency: text("sync_frequency") .$type() .notNull() .default("hourly"), syncEnabled: boolean("sync_enabled").notNull().default(false), lastSyncAt: timestamp("last_sync_at", { withTimezone: true }), lastSyncError: text("last_sync_error"), createdBy: text("created_by"), updatedBy: text("updated_by"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export const timeTrackingSmartsheetSyncQueue = pgTable( "time_tracking_smartsheet_sync_queue", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), logId: uuid("log_id") .notNull() .references(() => timeTrackingLogs.id, { onDelete: "cascade" }), smartsheetRowId: text("smartsheet_row_id"), status: text("status", { enum: ["pending", "syncing", "synced", "failed"], }) .notNull() .default("pending"), attempts: integer("attempts").notNull().default(0), lastError: text("last_error"), nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }) .notNull() .defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), syncedAt: timestamp("synced_at", { withTimezone: true }), }, ); export const timeTrackingSmartsheetSyncLog = pgTable( "time_tracking_smartsheet_sync_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), logId: uuid("log_id").references(() => timeTrackingLogs.id, { onDelete: "set null", }), smartsheetRowId: text("smartsheet_row_id"), action: text("action", { enum: ["sync", "retry", "skip", "queue"], }).notNull(), success: boolean("success").notNull(), error: text("error"), durationMs: integer("duration_ms"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, ); export type TimeTrackingSmartsheetConfig = typeof timeTrackingSmartsheetConfig.$inferSelect; export type TimeTrackingSmartsheetConfigInsert = typeof timeTrackingSmartsheetConfig.$inferInsert; export type TimeTrackingSmartsheetSyncQueue = typeof timeTrackingSmartsheetSyncQueue.$inferSelect; export type TimeTrackingSmartsheetSyncLog = typeof timeTrackingSmartsheetSyncLog.$inferSelect;