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
+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;