feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
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:
@@ -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"];
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user