feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s

The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.

Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
  IN ('worker','time_admin','irrigator','water_admin'),
  language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
  UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
  SET NOT NULL
- drops the two old tables and renames FK columns
  (irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
  field_worker_id

Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
  literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
  water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.

Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
  with zero NULL field_worker_id, index rebuilt.

PR-reviewer: APPROVED.
This commit is contained in:
Nora
2026-07-03 20:27:11 -06:00
parent ce652ff5de
commit 16ad0955f2
16 changed files with 623 additions and 223 deletions
+26 -29
View File
@@ -5,6 +5,13 @@
* 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.
*/
import {
pgTable,
@@ -18,6 +25,7 @@ import {
jsonb,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { fieldWorkers } from "./field-workers";
export const timeTrackingSettings = pgTable(
"time_tracking_settings",
@@ -55,29 +63,12 @@ export const timeTrackingSettings = pgTable(
},
);
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),
}),
);
// 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",
@@ -109,9 +100,10 @@ export const timeTrackingLogs = pgTable(
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id")
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
onDelete: "set null",
}),
@@ -129,7 +121,9 @@ export const timeTrackingLogs = pgTable(
},
(t) => ({
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
t.fieldWorkerId,
),
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
}),
);
@@ -141,8 +135,9 @@ export const timeTrackingNotificationLog = pgTable(
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id").references(
() => timeTrackingWorkers.id,
/** 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(),
@@ -160,7 +155,9 @@ export const timeTrackingNotificationLog = pgTable(
);
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
export type TimeTrackingWorker = typeof timeTrackingWorkers.$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 =