9e6accb3df
Phase 2 of the time-tracking buildout — turns the existing clock widget
into a full payroll-ready system for Drivers / Supervisors / Admins.
Roles
- field_workers CHECK extended with 'driver' and 'supervisor' alongside
the existing worker / time_admin / irrigator / water_admin values.
Schema (migration 0098)
- time_tracking_logs gains clock_in/out lat/lng/accuracy_m + gps_verified
+ entry_kind ('clock' | 'manual') + manual_reason + edit audit columns.
- new time_tracking_timesheets (draft → submitted → approved/locked →
rejected, with unlock path for admins).
- new time_tracking_audit_log (append-only).
- new time_tracking_supervisor_assignments (supervisor ↔ supervisee edges).
- new SECURITY DEFINER RPCs: recompute_timesheet_totals(),
get_or_create_timesheet(), plus a time_tracking_approval_queue view.
Workflow + locking (src/actions/time-tracking/timesheets.ts)
- resolveCaller() returns platform_admin / brand_admin / supervisor /
worker context; supervisor scope is enforced via the assignments table.
- submitTimesheet / approveTimesheet / rejectTimesheet / unlockTimesheet
all run inside withTx with FOR UPDATE row locks so the status + audit
row + recompute RPC commit atomically.
- Editing or deleting an entry on an approved timesheet is blocked at
the action layer; only unlockTimesheet (admin-only, mandatory audit
note) can re-open it.
Manual entry (src/actions/time-tracking/entries.ts)
- addManualTimeEntry / editTimeEntry / deleteTimeEntry with Zod validation,
audit-log writes in the same transaction, lock-aware.
GPS + offline (src/actions/time-tracking/field.ts,
src/lib/offline/time-tracking-queue.ts,
src/actions/time-tracking/offline-handlers.ts)
- captureGps() in the field client: best-effort geolocation, never blocks
clock-in/out (gps_verified = accuracy_m <= 100).
- tryOrQueueClock() wraps the action; on network failure the event lands
in IndexedDB and replays via replayOfflineClock() (PIN re-verified on
replay, submitted_via = 'offline_sync').
Export (src/actions/time-tracking/export.ts +
src/app/api/time-tracking/timesheets/[id]/export/route.ts)
- PDF (pdf-lib) + CSV (papaparse) payroll export with signature lines,
available for any approved timesheet.
UI
- /admin/time-tracking with tab nav (Overview / Timesheets / Approvals /
Audit).
- TimesheetsList, TimesheetDetail (status-driven workflow buttons,
manual entry form, audit trail), ManualEntryForm.
- FleetRouteSummary on the time-tracking overview shows the full crew's
water + time totals for the day.
- DailyRouteSummary on /admin/water-log/users/[id] shows the per-worker
cross-link badge (water gallons + time hours + timesheet status).
Verification
- npx tsc --noEmit → 0 errors.
- npm run build → all 10 time-tracking routes compile.
- npm run lint → 0 errors/warnings in new files.
- Playwright smoke → 4/5 pass; the failing case is a pre-existing
Tuxedo storefront 404 unrelated to this change.
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
|
|
// ── 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<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; |