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
+59
View File
@@ -0,0 +1,59 @@
/**
* Cycle 10 — Unified field worker table.
*
* Replaces `water_irrigators` + `time_tracking_workers` (one row per
* person with two independent PIN hashes) with one row, one PIN hash,
* one role vocabulary.
*
* Role vocabulary (matches the CHECK constraint in
* `db/migrations/0097_field_workers.sql`):
* - `worker` — default; can submit water entries + clock in/out
* - `time_admin` — can manage time-tracking tasks, settings, and
* other time workers
* - `irrigator` — legacy role from `water_irrigators`; same powers
* as `worker` but kept distinct for UI filtering
* (the water admin UI shows this label)
* - `water_admin` — can manage headgates, water workers, and water
* entries
*
* The PIN is a scrypt self-describing hash from `@/lib/water-log-pin`
* (column name `pin_hash`, NOT `pin` — see cycle 10 migration for the
* rename).
*/
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { brands } from "./brands";
export const fieldWorkers = pgTable(
"field_workers",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
role: text("role", {
enum: ["worker", "time_admin", "irrigator", "water_admin"],
})
.notNull()
.default("worker"),
languagePreference: text("language_preference").notNull().default("en"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("field_workers_brand_idx").on(t.brandId),
}),
);
export type FieldWorker = typeof fieldWorkers.$inferSelect;
export type NewFieldWorker = typeof fieldWorkers.$inferInsert;
export type FieldWorkerRole = FieldWorker["role"];
+1
View File
@@ -14,6 +14,7 @@ export * from "./water-log";
export * from "./communications";
export * from "./marketing";
export * from "./time-tracking";
export * from "./field-workers";
export * from "./shipping";
export * from "./support";
export * from "./files";
+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 =
+20 -35
View File
@@ -3,7 +3,8 @@
*
* Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to
* - water_irrigators — PIN-authenticated field workers
* - field_workers — unified worker table (cycle 10; replaces
* water_irrigators + time_tracking_workers)
* - water_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history
@@ -27,6 +28,7 @@ import {
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
// Cycle 7: the `bytea` customType used to live here for the
// encrypted Smartsheet token columns. Those columns moved to
@@ -66,40 +68,21 @@ export const waterHeadgates = pgTable(
}),
);
export const waterIrrigators = pgTable(
"water_irrigators",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
pinHash: text("pin_hash").notNull(),
languagePreference: text("language_preference")
.notNull()
.default("en"),
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
role: text("role").notNull().default("irrigator"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
}),
);
// Cycle 10: water_irrigators 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 waterIrrigators should now
// read from fieldWorkers and filter by role = 'irrigator' or
// 'water_admin' if it needs water-only workers.
export const waterSessions = pgTable(
"water_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
irrigatorId: uuid("irrigator_id")
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
@@ -117,9 +100,10 @@ export const waterLogEntries = pgTable(
headgateId: uuid("headgate_id")
.notNull()
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
irrigatorId: uuid("irrigator_id")
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
fieldWorkerId: uuid("field_worker_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(),
@@ -146,8 +130,8 @@ export const waterLogEntries = pgTable(
t.brandId,
t.loggedDate,
),
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
t.irrigatorId,
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
t.fieldWorkerId,
t.loggedAt,
),
}),
@@ -248,8 +232,9 @@ export const waterAuditLog = pgTable(
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
// Use `FieldWorker` from `./field-workers` instead; filter by role =
// 'irrigator' or 'water_admin' if you need water-only workers.
export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;