/** * 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`). * * Cycle 10: the `time_tracking_workers` table was DROPPED in * `db/migrations/0097_field_workers.sql`. Worker records now live * in `field_workers` (see `db/schema/field-workers.ts`). The * `worker_id` column on the downstream tables * (`time_tracking_logs`, `time_tracking_notification_log`) was * renamed to `field_worker_id` and the FK repointed. * * Cycle 12 (Phase 2): GPS columns + manual entries + audit log + * timesheet approval workflow (0098_time_tracking_timesheets.sql). */ import { pgTable, uuid, text, numeric, integer, boolean, timestamp, index, jsonb, date, doublePrecision, } from "drizzle-orm/pg-core"; import { brands, adminUsers } from "./brands"; import { fieldWorkers } from "./field-workers"; 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(), }, ); // Cycle 10: `time_tracking_workers` table was DROPPED in // 0097_field_workers.sql. Worker records now live in `field_workers` // (see `db/schema/field-workers.ts`). Any code that previously read // from timeTrackingWorkers should now read from fieldWorkers and // filter by role = 'worker' or 'time_admin' if it needs time-only // workers. 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" }), /** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */ fieldWorkerId: uuid("field_worker_id") .notNull() .references(() => fieldWorkers.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"), // ── Migration 0098: GPS + manual entries + edit audit ──────── /** `'clock'` = from the field app, `'manual'` = admin-entered. */ entryKind: text("entry_kind", { enum: ["clock", "manual"] }) .notNull() .default("clock"), /** Required when `entry_kind = 'manual'`. Surfaced in payroll exports. */ manualReason: text("manual_reason"), /** GPS columns — captured at clock-in / clock-out. Nullable: device * may have denied geolocation, in which case `gpsVerified = false`. */ clockInLat: doublePrecision("clock_in_lat"), clockInLng: doublePrecision("clock_in_lng"), clockInAccuracyM: doublePrecision("clock_in_accuracy_m"), clockOutLat: doublePrecision("clock_out_lat"), clockOutLng: doublePrecision("clock_out_lng"), clockOutAccuracyM: doublePrecision("clock_out_accuracy_m"), /** True iff the device supplied a fix with accuracy ≤ 100m. */ gpsVerified: boolean("gps_verified").notNull().default(false), /** Edit audit — filled every time a server action updates the row * after creation. Never cleared. */ editedAt: timestamp("edited_at", { withTimezone: true }), editedBy: uuid("edited_by").references(() => adminUsers.id, { onDelete: "set null", }), editReason: text("edit_reason"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId), fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on( t.fieldWorkerId, ), clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn), brandKindIdx: index("time_tracking_logs_brand_kind_idx").on( t.brandId, t.entryKind, 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" }), /** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */ fieldWorkerId: uuid("field_worker_id").references( () => fieldWorkers.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(), }, ); // ── Migration 0098: Timesheet + approval workflow ───────────────────────── export const TIMESHEET_STATUSES = [ "draft", "submitted", "approved", "rejected", ] as const; export type TimesheetStatus = (typeof TIMESHEET_STATUSES)[number]; export const timeTrackingTimesheets = pgTable( "time_tracking_timesheets", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), fieldWorkerId: uuid("field_worker_id") .notNull() .references(() => fieldWorkers.id, { onDelete: "cascade" }), periodStart: date("period_start").notNull(), periodEnd: date("period_end").notNull(), status: text("status", { enum: TIMESHEET_STATUSES }) .notNull() .default("draft"), // Submission submittedAt: timestamp("submitted_at", { withTimezone: true }), submittedBy: uuid("submitted_by").references(() => adminUsers.id, { onDelete: "set null", }), submittedNotes: text("submitted_notes"), // Approval (locks the timesheet) reviewedAt: timestamp("reviewed_at", { withTimezone: true }), reviewedBy: uuid("reviewed_by").references(() => adminUsers.id, { onDelete: "set null", }), reviewerComment: text("reviewer_comment"), // Rejection rejectedAt: timestamp("rejected_at", { withTimezone: true }), rejectedBy: uuid("rejected_by").references(() => adminUsers.id, { onDelete: "set null", }), rejectionReason: text("rejection_reason"), // Lock mirror (always equals reviewed_at when status=approved) lockedAt: timestamp("locked_at", { withTimezone: true }), lockedBy: uuid("locked_by").references(() => adminUsers.id, { onDelete: "set null", }), // Unlock (rare, admin-only, MUST carry an audit note) unlockedAt: timestamp("unlocked_at", { withTimezone: true }), unlockedBy: uuid("unlocked_by").references(() => adminUsers.id, { onDelete: "set null", }), unlockAuditNote: text("unlock_audit_note").notNull().default(""), // Pre-computed totals (recomputed by recompute_timesheet_totals RPC) totalMinutes: integer("total_minutes").notNull().default(0), regularMinutes: integer("regular_minutes").notNull().default(0), overtimeMinutes: integer("overtime_minutes").notNull().default(0), breakMinutes: integer("break_minutes").notNull().default(0), entryCount: integer("entry_count").notNull().default(0), /** Map of date string ("YYYY-MM-DD") → minutes worked that day. */ dailyTotals: jsonb("daily_totals").notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ workerPeriodUniq: index( "time_tracking_timesheets_worker_period_uniq", ).on(t.fieldWorkerId, t.periodStart, t.periodEnd), brandStatusIdx: index("time_tracking_timesheets_brand_status_idx").on( t.brandId, t.status, t.periodStart, ), brandWorkerIdx: index("time_tracking_timesheets_brand_worker_idx").on( t.brandId, t.fieldWorkerId, ), }), ); /** Append-only audit log — every mutating action on a timesheet or its * underlying logs MUST write a row here in the same DB transaction. */ export const timeTrackingAuditLog = pgTable( "time_tracking_audit_log", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), /** Admin user (or null when the actor was the worker themselves). */ actorId: uuid("actor_id").references(() => adminUsers.id, { onDelete: "set null", }), actorLabel: text("actor_label").notNull(), actorKind: text("actor_kind").notNull().default("admin"), action: text("action").notNull(), entityType: text("entity_type").notNull(), entityId: uuid("entity_id"), details: jsonb("details").notNull().default({}), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ brandRecentIdx: index("time_tracking_audit_log_brand_recent_idx").on( t.brandId, t.createdAt, ), entityIdx: index("time_tracking_audit_log_entity_idx").on( t.entityType, t.entityId, ), }), ); /** Supervisor ↔ supervisee edges. The brand admin assigns edges; the * approval queue filters by them. */ export const timeTrackingSupervisorAssignments = pgTable( "time_tracking_supervisor_assignments", { id: uuid("id").primaryKey().defaultRandom(), brandId: uuid("brand_id") .notNull() .references(() => brands.id, { onDelete: "cascade" }), supervisorFieldWorkerId: uuid("supervisor_field_worker_id") .notNull() .references(() => fieldWorkers.id, { onDelete: "cascade" }), superviseeFieldWorkerId: uuid("supervisee_field_worker_id") .notNull() .references(() => fieldWorkers.id, { onDelete: "cascade" }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, (t) => ({ uniq: index("time_tracking_supervisor_assignments_uniq").on( t.supervisorFieldWorkerId, t.superviseeFieldWorkerId, ), supervisorIdx: index( "time_tracking_supervisor_assignments_supervisor_idx", ).on(t.supervisorFieldWorkerId), }), ); export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect; // Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from // `./field-workers` instead; filter by role = 'worker' or 'time_admin' // if you need time-only workers. export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect; export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect; export type TimeTrackingNotificationLog = typeof timeTrackingNotificationLog.$inferSelect; export type TimeTrackingTimesheet = typeof timeTrackingTimesheets.$inferSelect; export type TimeTrackingTimesheetInsert = typeof timeTrackingTimesheets.$inferInsert; export type TimeTrackingAuditLogEntry = typeof timeTrackingAuditLog.$inferSelect; export type TimeTrackingSupervisorAssignment = typeof timeTrackingSupervisorAssignments.$inferSelect; // ── Inferred types ── // 0098 — daily totals JSON shape stored on the timesheet export type DailyTotals = Record; // ── 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;