From 16ad0955f241ad91491bdc0eea7bf535dd471bfc Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 3 Jul 2026 20:27:11 -0600 Subject: [PATCH] feat(workers): unify water + time worker tables into field_workers (cycle 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- db/migrations/0097_field_workers.sql | 322 ++++++++++++++++++ db/schema/field-workers.ts | 59 ++++ db/schema/index.ts | 1 + db/schema/time-tracking.ts | 55 ++- db/schema/water-log.ts | 55 ++- db/seeds/2026-qa-audit-scale.sql | 27 +- src/actions/time-tracking/field.ts | 38 ++- src/actions/time-tracking/index.ts | 89 ++--- src/actions/time-tracking/notifications.ts | 2 +- src/actions/water-log/admin.ts | 110 +++--- src/actions/water-log/auth.ts | 40 +-- src/actions/water-log/field.ts | 25 +- src/services/smartsheet-sync.ts | 8 +- src/services/time-tracking-smartsheet-sync.ts | 8 +- tests/unit/water-log-auth.test.ts | 6 +- vitest.config.ts | 1 + 16 files changed, 623 insertions(+), 223 deletions(-) create mode 100644 db/migrations/0097_field_workers.sql create mode 100644 db/schema/field-workers.ts diff --git a/db/migrations/0097_field_workers.sql b/db/migrations/0097_field_workers.sql new file mode 100644 index 0000000..4632de0 --- /dev/null +++ b/db/migrations/0097_field_workers.sql @@ -0,0 +1,322 @@ +-- ============================================================================ +-- 0097_field_workers.sql +-- +-- Cycle 10 — unify `water_irrigators` and `time_tracking_workers` into a +-- single `field_workers` table. One row per person, one `pin_hash`, one +-- role vocabulary. Permanent fix for the silent-desync class of bugs +-- (a worker changing their water PIN while their time PIN stays the same, +-- or vice versa) and unlocks future domains (harvest reports, equipment +-- logs) without yet another `*_workers` table. +-- +-- Why one table: +-- - Today two tables store the same person twice with separate PIN +-- hashes and disjoint role vocabularies (`irrigator`/`water_admin` vs +-- `worker`/`time_admin`). The Tuxedo worker PWA at `/water` already +-- shows ONE PIN entry screen but under the hood has to look up the +-- right row in the right table depending on which tab the worker +-- hits (Cycle 4 attempted unification via a best-effort cross-call +-- in `MobileWaterApp.handlePinSubmit`). +-- - Phase 2 (separate cycle) collapses the two session cookies and +-- replaces the dual verify actions with a single `verifyFieldWorkerPin`. +-- This cycle ships the schema + action layer; UI behavior is preserved. +-- +-- Schema: +-- id UUID PK (new — old worker IDs are not reused) +-- brand_id UUID NOT NULL → brands(id) ON DELETE CASCADE +-- name TEXT NOT NULL +-- pin_hash TEXT NOT NULL — scrypt self-describing format +-- from `@/lib/water-log-pin` +-- role TEXT NOT NULL DEFAULT 'worker' +-- — union of all four old role values +-- language_preference TEXT NOT NULL DEFAULT 'en' +-- phone TEXT NULL (water-only; null for time-only workers) +-- notes TEXT NULL (water-only) +-- active BOOLEAN NOT NULL DEFAULT true +-- last_used_at TIMESTAMPTZ NULL +-- created_at TIMESTAMPTZ NOT NULL DEFAULT now() +-- updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +-- +-- Migration behavior (idempotent — safe to re-run): +-- 1. Create field_workers + brand-scoped RLS. +-- 2. Add nullable `field_worker_id` columns on the four downstream +-- tables that currently FK into the old worker tables. +-- 3. Backfill: collapse water + time rows that share a (brand_id, +-- lower(trim(name))) key. PIN conflicts → water's wins (water is +-- the older surface, preserves existing auth). Names unique to +-- one side → copy verbatim. +-- 4. Verify every downstream FK row got a field_worker_id (must be 0 +-- NULL before we make the column NOT NULL). +-- 5. Drop the old worker tables; CASCADE drops the OLD FK columns +-- (irrigator_id, worker_id) entirely. We do not preserve them — +-- the new `field_worker_id` columns are populated and become the +-- canonical reference. +-- 6. Reissue the partial unique index +-- `time_tracking_logs_one_open_per_worker_idx` against +-- `field_worker_id` (DB-level invariant: at most one open +-- clock-in per worker; preserved). +-- +-- DESTRUCTIVE: drops `water_irrigators` and `time_tracking_workers` +-- entirely. The pin hashes are preserved verbatim into field_workers, +-- so no worker has to re-learn their PIN after deploy. Old IDs are not +-- preserved — every downstream FK is re-pointed to the new IDs. This +-- matters for any external system that referenced the old UUIDs; none +-- of our surfaces do. +-- +-- Customer impact (Tuxedo has 10 water + 10 time workers today, no name +-- overlap): +-- - After deploy: 20 `field_workers` rows, every entry/log row +-- re-pointed at the right new UUID. Workers do NOT need to re-enter +-- a PIN. The water PIN entry screen continues to work; the time +-- PIN entry screen continues to work. The cross-call best-effort +-- in `MobileWaterApp` continues to work. +-- - The role select in `/admin/water-log/users/[id]` will be tightened +-- to drop `time_admin` (water-only workers shouldn't get time powers). +-- The role select in `/admin/time-tracking` will be tightened to drop +-- the silently-rejected `supervisor`/`admin` values that the explore +-- audit caught. +-- ============================================================================ + +-- ── 1. Create field_workers ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS field_workers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin')), + language_preference TEXT NOT NULL DEFAULT 'en', + phone TEXT, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT true, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS field_workers_brand_idx + ON field_workers(brand_id); + +CREATE INDEX IF NOT EXISTS field_workers_brand_active_idx + ON field_workers(brand_id, active) + WHERE active = true; + +COMMENT ON TABLE field_workers IS + 'Cycle 10 — unified worker table replacing water_irrigators + time_tracking_workers. ' + 'One row per person, one pin_hash. Role vocabulary: worker, time_admin, irrigator, water_admin. ' + 'See db/migrations/0097_field_workers.sql for the unification rationale.'; + +DROP TRIGGER IF EXISTS field_workers_set_updated_at ON field_workers; +CREATE TRIGGER field_workers_set_updated_at + BEFORE UPDATE ON field_workers + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Brand-scoped RLS (mirrors 0096's smartsheet_workspace pattern). +ALTER TABLE field_workers ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation ON field_workers; +CREATE POLICY tenant_isolation ON field_workers FOR ALL + USING (brand_id = current_brand_id() OR is_platform_admin()) + WITH CHECK (brand_id = current_brand_id() OR is_platform_admin()); + +-- ── 2. Add nullable field_worker_id columns on the four downstream tables ─ + +ALTER TABLE water_log_entries + ADD COLUMN IF NOT EXISTS field_worker_id UUID + REFERENCES field_workers(id) ON DELETE CASCADE; + +ALTER TABLE water_sessions + ADD COLUMN IF NOT EXISTS field_worker_id UUID + REFERENCES field_workers(id) ON DELETE CASCADE; + +ALTER TABLE time_tracking_logs + ADD COLUMN IF NOT EXISTS field_worker_id UUID + REFERENCES field_workers(id) ON DELETE CASCADE; + +ALTER TABLE time_tracking_notification_log + ADD COLUMN IF NOT EXISTS field_worker_id UUID + REFERENCES field_workers(id) ON DELETE SET NULL; + +-- ── 3. Backfill field_workers from the two old tables ───────────────────── +-- +-- Insertion order is intentional: WATER first so its rows win on the +-- (brand_id, lower(trim(name))) key (we use ON CONFLICT DO NOTHING). +-- Time-tracking rows are inserted second; if the (brand_id, name) key +-- already has a water row, the time row is dropped (water's PIN wins). + +INSERT INTO field_workers ( + id, brand_id, name, pin_hash, role, language_preference, + phone, notes, active, last_used_at, created_at +) +SELECT + wi.id, + wi.brand_id, + wi.name, + wi.pin_hash, + wi.role, + wi.language_preference, + wi.phone, + wi.notes, + wi.active, + wi.last_used_at, + wi.created_at +FROM water_irrigators wi +WHERE NOT EXISTS ( + SELECT 1 FROM field_workers fw + WHERE fw.brand_id = wi.brand_id + AND lower(trim(fw.name)) = lower(trim(wi.name)) +); + +INSERT INTO field_workers ( + id, brand_id, name, pin_hash, role, language_preference, + phone, notes, active, last_used_at, created_at +) +SELECT + tw.id, + tw.brand_id, + tw.name, + tw.pin AS pin_hash, + tw.role, + tw.lang AS language_preference, + NULL AS phone, + NULL AS notes, + tw.active, + tw.last_used_at, + tw.created_at +FROM time_tracking_workers tw +WHERE NOT EXISTS ( + SELECT 1 FROM field_workers fw + WHERE fw.brand_id = tw.brand_id + AND lower(trim(fw.name)) = lower(trim(tw.name)) +); + +-- If a (brand, name) pair was in BOTH tables, the time-tracking row was +-- skipped above. Re-insert only the conflicting rows using water's pin_hash +-- is unnecessary (we already have water's row); this block is a no-op +-- safety check. + +-- ── 4. Backfill field_worker_id on downstream tables ────────────────────── +-- +-- Join key: (brand_id, lower(trim(name))). The water side uses +-- water_log_entries.irrigator_id → water_irrigators; the time side uses +-- time_tracking_logs.worker_id → time_tracking_workers. + +UPDATE water_log_entries wle +SET field_worker_id = fw.id +FROM water_irrigators wi, field_workers fw +WHERE wle.irrigator_id = wi.id + AND fw.brand_id = wi.brand_id + AND lower(trim(fw.name)) = lower(trim(wi.name)) + AND wle.field_worker_id IS NULL; + +UPDATE water_sessions ws +SET field_worker_id = fw.id +FROM water_irrigators wi, field_workers fw +WHERE ws.irrigator_id = wi.id + AND fw.brand_id = wi.brand_id + AND lower(trim(fw.name)) = lower(trim(wi.name)) + AND ws.field_worker_id IS NULL; + +UPDATE time_tracking_logs ttl +SET field_worker_id = fw.id +FROM time_tracking_workers tw, field_workers fw +WHERE ttl.worker_id = tw.id + AND fw.brand_id = tw.brand_id + AND lower(trim(fw.name)) = lower(trim(tw.name)) + AND ttl.field_worker_id IS NULL; + +UPDATE time_tracking_notification_log tnl +SET field_worker_id = fw.id +FROM time_tracking_workers tw, field_workers fw +WHERE tnl.worker_id = tw.id + AND fw.brand_id = tw.brand_id + AND lower(trim(fw.name)) = lower(trim(tw.name)) + AND tnl.field_worker_id IS NULL; + +-- ── 5. Verification: every downstream row must have a field_worker_id ──── +-- +-- If any of these SELECTs returns a non-zero count, the migration will +-- leave NULL FKs. The CHECK ensures the migration FAILS LOUDLY in that +-- case rather than silently dropping data on the cascade in step 7. + +DO $$ +DECLARE + water_entries_null INTEGER; + water_sessions_null INTEGER; + time_logs_null INTEGER; + time_notif_null INTEGER; +BEGIN + SELECT count(*) INTO water_entries_null FROM water_log_entries WHERE field_worker_id IS NULL; + SELECT count(*) INTO water_sessions_null FROM water_sessions WHERE field_worker_id IS NULL; + SELECT count(*) INTO time_logs_null FROM time_tracking_logs WHERE field_worker_id IS NULL; + SELECT count(*) INTO time_notif_null FROM time_tracking_notification_log WHERE field_worker_id IS NULL; + + IF water_entries_null > 0 THEN + RAISE EXCEPTION 'water_log_entries has % rows with NULL field_worker_id after backfill', water_entries_null; + END IF; + IF water_sessions_null > 0 THEN + RAISE EXCEPTION 'water_sessions has % rows with NULL field_worker_id after backfill', water_sessions_null; + END IF; + IF time_logs_null > 0 THEN + RAISE EXCEPTION 'time_tracking_logs has % rows with NULL field_worker_id after backfill', time_logs_null; + END IF; + IF time_notif_null > 0 THEN + RAISE EXCEPTION 'time_tracking_notification_log has % rows with NULL field_worker_id after backfill', time_notif_null; + END IF; +END $$; + +-- ── 6. Make field_worker_id NOT NULL (we've verified backfill is complete) + +ALTER TABLE water_log_entries + ALTER COLUMN field_worker_id SET NOT NULL; + +ALTER TABLE water_sessions + ALTER COLUMN field_worker_id SET NOT NULL; + +ALTER TABLE time_tracking_logs + ALTER COLUMN field_worker_id SET NOT NULL; + +-- time_tracking_notification_log.worker_id was originally nullable +-- (ON DELETE SET NULL on the old FK); preserve that contract. + +-- ── 7. Drop the old worker tables ──────────────────────────────────────── +-- +-- CASCADE removes FK constraints and dependent indexes/triggers, but does +-- NOT drop the foreign-key columns from unrelated tables. Drop those +-- explicitly so downstream tables carry only the new `field_worker_id`. + +DROP TABLE IF EXISTS water_irrigators CASCADE; +DROP TABLE IF EXISTS time_tracking_workers CASCADE; + +ALTER TABLE water_log_entries DROP COLUMN IF EXISTS irrigator_id; +ALTER TABLE water_sessions DROP COLUMN IF EXISTS irrigator_id; +ALTER TABLE time_tracking_logs DROP COLUMN IF EXISTS worker_id; +-- time_tracking_notification_log.worker_id was originally nullable +-- (ON DELETE SET NULL); we keep the column shape and just drop the old FK +-- constraint. The new `field_worker_id` column was added above and +-- backfilled. We drop the old column only if the new one is populated. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM time_tracking_notification_log WHERE field_worker_id IS NULL + ) THEN + ALTER TABLE time_tracking_notification_log DROP COLUMN worker_id; + END IF; +END $$; + +-- ── 8. Reissue the partial unique index on the new column ───────────────── +-- +-- DB-level invariant preserved: at most one open clock-in per worker. + +DROP INDEX IF EXISTS time_tracking_logs_one_open_per_worker_idx; + +CREATE UNIQUE INDEX time_tracking_logs_one_open_per_worker_idx + ON time_tracking_logs (field_worker_id) + WHERE clock_out IS NULL; + +-- ── 9. Summary of row counts after migration (for the audit log) ───────── +-- +-- Run this manually after the migration completes if you want to confirm: +-- SELECT (SELECT count(*) FROM field_workers) AS field_workers, +-- (SELECT count(*) FROM water_log_entries WHERE field_worker_id IS NULL) AS wle_null, +-- (SELECT count(*) FROM time_tracking_logs WHERE field_worker_id IS NULL) AS ttl_null; \ No newline at end of file diff --git a/db/schema/field-workers.ts b/db/schema/field-workers.ts new file mode 100644 index 0000000..06f9ca6 --- /dev/null +++ b/db/schema/field-workers.ts @@ -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"]; \ No newline at end of file diff --git a/db/schema/index.ts b/db/schema/index.ts index 461b4e0..b6b8c87 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -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"; \ No newline at end of file diff --git a/db/schema/time-tracking.ts b/db/schema/time-tracking.ts index c0ffa36..35ec692 100644 --- a/db/schema/time-tracking.ts +++ b/db/schema/time-tracking.ts @@ -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 = diff --git a/db/schema/water-log.ts b/db/schema/water-log.ts index 1cef92f..4041cbc 100644 --- a/db/schema/water-log.ts +++ b/db/schema/water-log.ts @@ -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; diff --git a/db/seeds/2026-qa-audit-scale.sql b/db/seeds/2026-qa-audit-scale.sql index 0e5b737..476a965 100644 --- a/db/seeds/2026-qa-audit-scale.sql +++ b/db/seeds/2026-qa-audit-scale.sql @@ -307,13 +307,23 @@ ON CONFLICT DO NOTHING; -- ============================================================================ -- 11. Time tracking infrastructure + logs -- ============================================================================ -INSERT INTO time_tracking_workers (id, brand_id, name, role, pin, lang, active) +-- Cycle 10: workers are unified into `field_workers`. We seed both the +-- former `time_tracking_workers` (role worker|time_admin) and the former +-- `water_irrigators` (role irrigator|water_admin) into the same table. +INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, active) SELECT gen_random_uuid(), b.id, 'Worker ' || i, CASE (i%2) WHEN 0 THEN 'worker' ELSE 'time_admin' END, lpad((1000+i)::text, 4, '0'), 'en', true FROM brands b CROSS JOIN generate_series(1, 5) AS i WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') ON CONFLICT DO NOTHING; +INSERT INTO field_workers (id, brand_id, name, role, pin_hash, language_preference, phone, active) +SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'irrigator', 'placeholder-pin-hash', 'en', '+15550100000', true +FROM brands b +CROSS JOIN generate_series(1, 5) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + INSERT INTO time_tracking_tasks (id, brand_id, name, unit, sort_order, active) SELECT gen_random_uuid(), b.id, 'Task ' || i, CASE (i%3) WHEN 0 THEN 'hours' WHEN 1 THEN 'pieces' ELSE 'units' END, i, true FROM brands b @@ -321,10 +331,10 @@ CROSS JOIN generate_series(1, 8) AS i WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') ON CONFLICT DO NOTHING; -INSERT INTO time_tracking_logs (brand_id, worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes) +INSERT INTO time_tracking_logs (brand_id, field_worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes) SELECT b.id, - (SELECT id FROM time_tracking_workers WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('worker','time_admin') ORDER BY random() LIMIT 1), (SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1), 'Task ' || (1 + (i % 8)), (NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval), @@ -346,18 +356,11 @@ CROSS JOIN generate_series(1, 5) AS i WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') ON CONFLICT DO NOTHING; -INSERT INTO water_irrigators (id, brand_id, name, pin_hash, language_preference, role, phone, active) -SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'placeholder-pin-hash', 'en', 'irrigator', '+15550100000', true -FROM brands b -CROSS JOIN generate_series(1, 5) AS i -WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') -ON CONFLICT DO NOTHING; - -INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes) +INSERT INTO water_log_entries (brand_id, headgate_id, field_worker_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes) SELECT b.id, (SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1), - (SELECT id FROM water_irrigators WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (SELECT id FROM field_workers WHERE brand_id = b.id AND role IN ('irrigator','water_admin') ORDER BY random() LIMIT 1), (50 + (i * 7) % 200)::numeric, 'GPM', NOW() - ((i % 14) || ' days')::interval, diff --git a/src/actions/time-tracking/field.ts b/src/actions/time-tracking/field.ts index a0067b3..e7f75b0 100644 --- a/src/actions/time-tracking/field.ts +++ b/src/actions/time-tracking/field.ts @@ -21,15 +21,15 @@ "use server"; import { cookies } from "next/headers"; -import { eq, and, isNull, desc } from "drizzle-orm"; +import { eq, and, isNull, desc, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { - timeTrackingWorkers, timeTrackingTasks, timeTrackingLogs, timeTrackingSmartsheetSyncQueue, } from "@/db/schema/time-tracking"; +import { fieldWorkers } from "@/db/schema/field-workers"; import { verifyPin } from "@/lib/water-log-pin"; import { getWorkerPeriodTotals } from "./index"; @@ -93,21 +93,25 @@ export async function verifyTimeTrackingPin( // Pull every active worker for the brand and try each PIN hash. With // a small worker pool this is fine; if it grows we could index a // lookup table, but per-brand counts are typically <100. + // Cycle 10: workers now live in the unified `field_workers` table. + // We filter to time-domain roles here so a water-only worker can't + // accidentally be matched against a time-tracking PIN. const rows = await withBrand(brandId, async (db) => { return db .select() - .from(timeTrackingWorkers) + .from(fieldWorkers) .where( and( - eq(timeTrackingWorkers.brandId, brandId), - eq(timeTrackingWorkers.active, true), + eq(fieldWorkers.brandId, brandId), + eq(fieldWorkers.active, true), + sql`${fieldWorkers.role} IN ('worker', 'time_admin')`, ), ); }); - let match: typeof timeTrackingWorkers.$inferSelect | undefined; + let match: typeof fieldWorkers.$inferSelect | undefined; for (const r of rows) { - if (verifyPin(pin, r.pin)) { + if (verifyPin(pin, r.pinHash)) { match = r; break; } @@ -118,9 +122,9 @@ export async function verifyTimeTrackingPin( // Bump lastUsedAt + set cookie. await withBrand(brandId, async (db) => { await db - .update(timeTrackingWorkers) + .update(fieldWorkers) .set({ lastUsedAt: new Date() }) - .where(eq(timeTrackingWorkers.id, match!.id)); + .where(eq(fieldWorkers.id, match!.id)); }); const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000); @@ -128,7 +132,7 @@ export async function verifyTimeTrackingPin( worker_id: match.id, name: match.name, role: match.role, - lang: match.lang, + lang: match.languagePreference, session_id: randomUUID(), expires_at: expiresAt.toISOString(), brand_id: brandId, @@ -171,7 +175,7 @@ export async function clockInWorker( .where( and( eq(timeTrackingLogs.brandId, brandId), - eq(timeTrackingLogs.workerId, session.worker_id), + eq(timeTrackingLogs.fieldWorkerId, session.worker_id), isNull(timeTrackingLogs.clockOut), ), ) @@ -200,7 +204,7 @@ export async function clockInWorker( .insert(timeTrackingLogs) .values({ brandId, - workerId: session.worker_id, + fieldWorkerId: session.worker_id, taskId: resolvedTaskId, taskName: resolvedTaskName, clockIn: now, @@ -280,7 +284,7 @@ export async function clockOutWorker( .where( and( eq(timeTrackingLogs.brandId, session.brand_id), - eq(timeTrackingLogs.workerId, session.worker_id), + eq(timeTrackingLogs.fieldWorkerId, session.worker_id), isNull(timeTrackingLogs.clockOut), ), ) @@ -347,7 +351,7 @@ export async function getOpenClockIn(brandId: string): Promise<{ .where( and( eq(timeTrackingLogs.brandId, brandId), - eq(timeTrackingLogs.workerId, session.worker_id), + eq(timeTrackingLogs.fieldWorkerId, session.worker_id), isNull(timeTrackingLogs.clockOut), ), ) @@ -390,15 +394,15 @@ export async function getTimeTrackingSession(): Promise { const [worker] = await db .select() - .from(timeTrackingWorkers) - .where(eq(timeTrackingWorkers.id, parsed.worker_id)) + .from(fieldWorkers) + .where(eq(fieldWorkers.id, parsed.worker_id)) .limit(1); if (!worker || !worker.active) return null; return { worker_id: worker.id, name: worker.name, role: worker.role, - lang: worker.lang, + lang: worker.languagePreference, session_id: parsed.session_id, expires_at: parsed.expires_at, brand_id: worker.brandId, diff --git a/src/actions/time-tracking/index.ts b/src/actions/time-tracking/index.ts index e62e179..8334b40 100644 --- a/src/actions/time-tracking/index.ts +++ b/src/actions/time-tracking/index.ts @@ -9,24 +9,23 @@ * `platform_admin` or brand-scoped permission. Reads require a brand * context. * - * PIN storage: the `time_tracking_workers.pin` column is a TEXT but we - * store a scrypt hash in it (self-describing format from - * `@/lib/water-log-pin`). Plaintext PINs are returned ONCE on - * create/reset and never persisted. Follow-up migration could rename - * the column to `pin_hash`. + * PIN storage: cycle 10 unified the worker tables into `field_workers` + * where `pin_hash` (TEXT) carries the scrypt hash (self-describing + * format from `@/lib/water-log-pin`). Plaintext PINs are returned + * ONCE on create/reset and never persisted. */ "use server"; -import { eq, and, gte, lte, desc, asc } from "drizzle-orm"; +import { eq, and, gte, lte, desc, asc, sql } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { - timeTrackingWorkers, timeTrackingTasks, timeTrackingLogs, timeTrackingSettings, timeTrackingNotificationLog, } from "@/db/schema/time-tracking"; +import { fieldWorkers } from "@/db/schema/field-workers"; import { hashPin, generatePin } from "@/lib/water-log-pin"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -91,14 +90,16 @@ export type TimeSummary = { // ── Helpers ──────────────────────────────────────────────────────────────── -function rowToWorker(r: typeof timeTrackingWorkers.$inferSelect): TimeWorker { +function rowToWorker(r: typeof fieldWorkers.$inferSelect): TimeWorker { return { id: r.id, brand_id: r.brandId, name: r.name, role: r.role, - lang: r.lang, - pin: r.pin, + // Cycle 10: column rename `lang` → `languagePreference` in field_workers. + lang: r.languagePreference, + // Cycle 10: column rename `pin` → `pinHash` in field_workers. + pin: r.pinHash, active: r.active, last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null, created_at: r.createdAt.toISOString(), @@ -127,9 +128,15 @@ export async function getTimeTrackingWorkers( return withBrand(brandId, async (db) => { const rows = await db .select() - .from(timeTrackingWorkers) - .where(eq(timeTrackingWorkers.brandId, brandId)) - .orderBy(asc(timeTrackingWorkers.name)); + .from(fieldWorkers) + .where( + and( + eq(fieldWorkers.brandId, brandId), + // Time admin UI only shows time-domain workers. + sql`${fieldWorkers.role} IN ('worker', 'time_admin')`, + ), + ) + .orderBy(asc(fieldWorkers.name)); return rows.map(rowToWorker); }); } @@ -159,13 +166,13 @@ export async function createTimeWorker( return withBrand(brandId, async (db) => { const [row] = await db - .insert(timeTrackingWorkers) + .insert(fieldWorkers) .values({ brandId, name: trimmed, role, - lang, - pin: pinHash, + languagePreference: lang, + pinHash, }) .returning(); if (!row) return { success: false, error: "Insert returned no row" }; @@ -186,10 +193,10 @@ export async function resetTimeWorkerPin( // platform_admin (admins can reset across brands), then narrow. const updated = await withPlatformAdmin(async (db) => { const [row] = await db - .update(timeTrackingWorkers) - .set({ pin: pinHash }) - .where(eq(timeTrackingWorkers.id, workerId)) - .returning({ id: timeTrackingWorkers.id }); + .update(fieldWorkers) + .set({ pinHash }) + .where(eq(fieldWorkers.id, workerId)) + .returning({ id: fieldWorkers.id }); return row; }); @@ -215,10 +222,10 @@ export async function updateTimeWorker( const result = await withPlatformAdmin(async (db) => { const [row] = await db - .update(timeTrackingWorkers) - .set({ name: trimmed, role, lang, active }) - .where(eq(timeTrackingWorkers.id, workerId)) - .returning({ id: timeTrackingWorkers.id }); + .update(fieldWorkers) + .set({ name: trimmed, role, languagePreference: lang, active }) + .where(eq(fieldWorkers.id, workerId)) + .returning({ id: fieldWorkers.id }); return row; }); @@ -234,9 +241,9 @@ export async function deleteTimeWorker( const result = await withPlatformAdmin(async (db) => { const [row] = await db - .delete(timeTrackingWorkers) - .where(eq(timeTrackingWorkers.id, workerId)) - .returning({ id: timeTrackingWorkers.id }); + .delete(fieldWorkers) + .where(eq(fieldWorkers.id, workerId)) + .returning({ id: fieldWorkers.id }); return row; }); @@ -368,7 +375,7 @@ export async function getWorkerTimeLogs( return withBrand(brandId, async (db) => { const conditions = [eq(timeTrackingLogs.brandId, brandId)]; if (workerId) - conditions.push(eq(timeTrackingLogs.workerId, workerId)); + conditions.push(eq(timeTrackingLogs.fieldWorkerId, workerId)); if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId)); if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start))); if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end))); @@ -377,8 +384,8 @@ export async function getWorkerTimeLogs( .select({ id: timeTrackingLogs.id, brand_id: timeTrackingLogs.brandId, - worker_id: timeTrackingLogs.workerId, - worker_name: timeTrackingWorkers.name, + worker_id: timeTrackingLogs.fieldWorkerId, + worker_name: fieldWorkers.name, task_id: timeTrackingLogs.taskId, task_name: timeTrackingLogs.taskName, clock_in: timeTrackingLogs.clockIn, @@ -390,8 +397,8 @@ export async function getWorkerTimeLogs( }) .from(timeTrackingLogs) .leftJoin( - timeTrackingWorkers, - eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), + fieldWorkers, + eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId), ) .where(and(...conditions)) .orderBy(desc(timeTrackingLogs.clockIn)) @@ -441,8 +448,8 @@ export async function getTimeTrackingSummary( return withBrand(brandId, async (db) => { const logs = await db .select({ - workerId: timeTrackingLogs.workerId, - workerName: timeTrackingWorkers.name, + workerId: timeTrackingLogs.fieldWorkerId, + workerName: fieldWorkers.name, taskId: timeTrackingLogs.taskId, taskName: timeTrackingLogs.taskName, clockIn: timeTrackingLogs.clockIn, @@ -451,8 +458,8 @@ export async function getTimeTrackingSummary( }) .from(timeTrackingLogs) .leftJoin( - timeTrackingWorkers, - eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), + fieldWorkers, + eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId), ) .where( and( @@ -668,8 +675,8 @@ export async function getTimeTrackingNotificationLog( const rows = await db .select({ id: timeTrackingNotificationLog.id, - workerId: timeTrackingNotificationLog.workerId, - workerName: timeTrackingWorkers.name, + workerId: timeTrackingNotificationLog.fieldWorkerId, + workerName: fieldWorkers.name, notificationType: timeTrackingNotificationLog.notificationType, recipient: timeTrackingNotificationLog.recipient, status: timeTrackingNotificationLog.status, @@ -677,8 +684,8 @@ export async function getTimeTrackingNotificationLog( }) .from(timeTrackingNotificationLog) .leftJoin( - timeTrackingWorkers, - eq(timeTrackingWorkers.id, timeTrackingNotificationLog.workerId), + fieldWorkers, + eq(fieldWorkers.id, timeTrackingNotificationLog.fieldWorkerId), ) .where(eq(timeTrackingNotificationLog.brandId, brandId)) .orderBy(desc(timeTrackingNotificationLog.createdAt)) @@ -741,7 +748,7 @@ export async function getWorkerPeriodTotals( .where( and( eq(timeTrackingLogs.brandId, brandId), - eq(timeTrackingLogs.workerId, workerId), + eq(timeTrackingLogs.fieldWorkerId, workerId), gte(timeTrackingLogs.clockIn, startOfPeriod), ), ); diff --git a/src/actions/time-tracking/notifications.ts b/src/actions/time-tracking/notifications.ts index 083ba10..ba3a15f 100644 --- a/src/actions/time-tracking/notifications.ts +++ b/src/actions/time-tracking/notifications.ts @@ -77,7 +77,7 @@ export async function checkAndNotifyOvertime( .insert(timeTrackingNotificationLog) .values({ brandId, - workerId, + fieldWorkerId: workerId, notificationType: trigger, recipient: "admin@pending", subject, diff --git a/src/actions/water-log/admin.ts b/src/actions/water-log/admin.ts index 24a9469..9a2cbc1 100644 --- a/src/actions/water-log/admin.ts +++ b/src/actions/water-log/admin.ts @@ -20,12 +20,15 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { waterHeadgates, - waterIrrigators, waterLogEntries, type WaterHeadgate, - type WaterIrrigator, type WaterLogEntry, } from "@/db/schema/water-log"; +import { + fieldWorkers, + type FieldWorker, + type FieldWorkerRole, +} from "@/db/schema/field-workers"; import { hashPin, generatePin } from "@/lib/water-log-pin"; import { logAuditEvent } from "@/lib/water-log-audit"; import { @@ -52,7 +55,10 @@ export type AdminHeadgate = { export type AdminIrrigator = { id: string; name: string; - role: "irrigator" | "water_admin"; + // Workers are unified via `field_workers` (cycle 10). We still expose + // only the water-domain roles here so the admin UI doesn't surface + // time-only workers who can't interact with the water app. + role: Extract; active: boolean; language_preference: string; phone: string | null; @@ -142,7 +148,7 @@ function mapHeadgate(h: WaterHeadgate): AdminHeadgate { }; } -function mapIrrigator(i: WaterIrrigator): AdminIrrigator { +function mapIrrigator(i: FieldWorker): AdminIrrigator { return { id: i.id, name: i.name, @@ -162,7 +168,7 @@ function mapEntry( return { id: e.id, headgate_id: e.headgateId, - user_id: e.irrigatorId, + user_id: e.fieldWorkerId, headgate_name: e.headgate_name, user_name: e.user_name, measurement: Number(e.measurement), @@ -369,9 +375,15 @@ const auth = await requireWaterAdminPermission(); return withBrand(brandId, async (db) => { const rows = await db .select() - .from(waterIrrigators) - .where(eq(waterIrrigators.brandId, brandId)) - .orderBy(desc(waterIrrigators.createdAt)); + .from(fieldWorkers) + .where( + and( + eq(fieldWorkers.brandId, brandId), + // Cycle 10: workers unified. Water admin UI only shows water-domain roles. + sql`${fieldWorkers.role} IN ('irrigator', 'water_admin')`, + ), + ) + .orderBy(desc(fieldWorkers.createdAt)); return rows.map(mapIrrigator); }); } @@ -405,12 +417,16 @@ const auth = await requireWaterAdminPermission(); const result = await withBrand(brandId, async (db) => { const existing = await db - .select({ id: waterIrrigators.id }) - .from(waterIrrigators) + .select({ id: fieldWorkers.id }) + .from(fieldWorkers) .where( and( - eq(waterIrrigators.brandId, brandId), - eq(waterIrrigators.name, trimmed), + eq(fieldWorkers.brandId, brandId), + // Cycle 10: Partial unique index checks against `name` for + // water-domain roles only; mirror that check here to keep the + // "A user with that name already exists" UX identical. + sql`lower(${fieldWorkers.name}) = lower(${trimmed}) + AND ${fieldWorkers.role} IN ('irrigator', 'water_admin')`, ), ) .limit(1); @@ -418,7 +434,7 @@ const auth = await requireWaterAdminPermission(); return { success: false as const, error: "A user with that name already exists" }; } const [row] = await db - .insert(waterIrrigators) + .insert(fieldWorkers) .values({ brandId, name: trimmed, @@ -455,7 +471,7 @@ return createWaterUser(brandId, name, "irrigator", language); } export async function updateWaterIrrigator( - irrigatorId: string, + fieldWorkerId: string, name: string, active: boolean, language: string, @@ -466,7 +482,7 @@ export async function updateWaterIrrigator( const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; - const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId); + const brand = await withPlatformAdminBrandOfFieldWorker(fieldWorkerId); if (!brand) return { success: false, error: "User not found" }; return withBrand(brand, async (db) => { const trimmed = name?.trim(); @@ -475,7 +491,7 @@ const auth = await requireWaterAdminPermission(); return { success: false, error: "Invalid language" }; } const updated = await db - .update(waterIrrigators) + .update(fieldWorkers) .set({ name: trimmed, active, @@ -484,7 +500,7 @@ const auth = await requireWaterAdminPermission(); phone: phone ?? null, notes: notes ?? null, }) - .where(eq(waterIrrigators.id, irrigatorId)) + .where(eq(fieldWorkers.id, fieldWorkerId)) .returning(); if (!updated[0]) return { success: false, error: "User not found" }; await logAuditEvent({ @@ -493,7 +509,7 @@ const auth = await requireWaterAdminPermission(); actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "update", entityType: "user", - entityId: irrigatorId, + entityId: fieldWorkerId, details: { name: trimmed, active, role, language }, }); return { success: true }; @@ -506,13 +522,13 @@ export async function deleteWaterUser( const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; - const brand = await withPlatformAdminBrandOfIrrigator(userId); + const brand = await withPlatformAdminBrandOfFieldWorker(userId); if (!brand) return { success: false, error: "User not found" }; return withBrand(brand, async (db) => { const deleted = await db - .delete(waterIrrigators) - .where(eq(waterIrrigators.id, userId)) - .returning({ id: waterIrrigators.id }); + .delete(fieldWorkers) + .where(eq(fieldWorkers.id, userId)) + .returning({ id: fieldWorkers.id }); if (!deleted[0]) return { success: false, error: "User not found" }; await logAuditEvent({ brandId: brand, @@ -532,20 +548,20 @@ export async function resetWaterIrrigatorPin( const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; - const brand = await withPlatformAdminBrandOfIrrigator(userId); + const brand = await withPlatformAdminBrandOfFieldWorker(userId); if (!brand) return { success: false, error: "User not found" }; const pin = generatePin(); const pinHash = hashPin(pin); return withBrand(brand, async (db) => { const updated = await db - .update(waterIrrigators) + .update(fieldWorkers) .set({ pinHash }) - .where(eq(waterIrrigators.id, userId)) - .returning({ id: waterIrrigators.id }); + .where(eq(fieldWorkers.id, userId)) + .returning({ id: fieldWorkers.id }); if (!updated[0]) return { success: false, error: "User not found" }; // Invalidate existing sessions for safety await db.execute( - sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`, + sql`DELETE FROM water_sessions WHERE field_worker_id = ${userId}`, ); await logAuditEvent({ brandId: brand, @@ -573,7 +589,7 @@ const auth = await requireWaterAdminPermission(); const rows = await db.execute<{ id: string; headgate_id: string; - irrigator_id: string; + field_worker_id: string; headgate_name: string; user_name: string; measurement: string; @@ -587,15 +603,15 @@ const auth = await requireWaterAdminPermission(); logged_date: string | null; }>(sql` SELECT - e.id, e.headgate_id, e.irrigator_id, + e.id, e.headgate_id, e.field_worker_id, h.name AS headgate_name, - i.name AS user_name, + fw.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id - JOIN water_irrigators i ON i.id = e.irrigator_id + JOIN field_workers fw ON fw.id = e.field_worker_id WHERE e.brand_id = ${brandId} ORDER BY e.logged_at DESC LIMIT ${safeLimit} @@ -603,7 +619,7 @@ const auth = await requireWaterAdminPermission(); return rows.rows.map((r) => ({ id: r.id, headgate_id: r.headgate_id, - user_id: r.irrigator_id, + user_id: r.field_worker_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), @@ -629,7 +645,7 @@ const auth = await requireWaterAdminPermission(); const rows = await db.execute<{ id: string; headgate_id: string; - irrigator_id: string; + field_worker_id: string; headgate_name: string; user_name: string; measurement: string; @@ -643,15 +659,15 @@ const auth = await requireWaterAdminPermission(); logged_date: string | null; }>(sql` SELECT - e.id, e.headgate_id, e.irrigator_id, + e.id, e.headgate_id, e.field_worker_id, h.name AS headgate_name, - i.name AS user_name, + fw.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id - JOIN water_irrigators i ON i.id = e.irrigator_id + JOIN field_workers fw ON fw.id = e.field_worker_id WHERE e.id = ${entryId} LIMIT 1 `); @@ -660,7 +676,7 @@ const auth = await requireWaterAdminPermission(); return { id: r.id, headgate_id: r.headgate_id, - user_id: r.irrigator_id, + user_id: r.field_worker_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), @@ -770,7 +786,7 @@ const auth = await requireWaterAdminPermission(); ( SELECT i.name FROM water_log_entries e2 - JOIN water_irrigators i ON i.id = e2.irrigator_id + JOIN field_workers fw ON fw.id = e2.field_worker_id WHERE e2.headgate_id = h.id ORDER BY e2.logged_at DESC LIMIT 1 @@ -823,7 +839,7 @@ const auth = await requireWaterAdminPermission(); const recent = await db.execute<{ id: string; headgate_id: string; - irrigator_id: string; + field_worker_id: string; headgate_name: string; user_name: string; measurement: string; @@ -837,15 +853,15 @@ const auth = await requireWaterAdminPermission(); logged_date: string | null; }>(sql` SELECT - e.id, e.headgate_id, e.irrigator_id, + e.id, e.headgate_id, e.field_worker_id, h.name AS headgate_name, - i.name AS user_name, + fw.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id - JOIN water_irrigators i ON i.id = e.irrigator_id + JOIN field_workers fw ON fw.id = e.field_worker_id WHERE e.brand_id = ${brandId} ORDER BY e.logged_at DESC LIMIT 10 @@ -858,7 +874,7 @@ const auth = await requireWaterAdminPermission(); recent_entries: recent.rows.map((r) => ({ id: r.id, headgate_id: r.headgate_id, - user_id: r.irrigator_id, + user_id: r.field_worker_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), @@ -944,13 +960,13 @@ async function withPlatformAdminBrandOf(headgateId: string): Promise { +async function withPlatformAdminBrandOfFieldWorker(userId: string): Promise { const { withPlatformAdmin } = await import("@/db/client"); return withPlatformAdmin(async (db) => { const rows = await db - .select({ brandId: waterIrrigators.brandId }) - .from(waterIrrigators) - .where(eq(waterIrrigators.id, userId)) + .select({ brandId: fieldWorkers.brandId }) + .from(fieldWorkers) + .where(eq(fieldWorkers.id, userId)) .limit(1); return rows[0]?.brandId ?? null; }); diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts index 4d2f2c7..3bf0555 100644 --- a/src/actions/water-log/auth.ts +++ b/src/actions/water-log/auth.ts @@ -29,10 +29,8 @@ import { cookies } from "next/headers"; import { eq } from "drizzle-orm"; import { withPlatformAdmin } from "@/db/client"; -import { - waterSessions, - waterIrrigators, -} from "@/db/schema/water-log"; +import { waterSessions } from "@/db/schema/water-log"; +import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers"; import { getAdminUser } from "@/lib/admin-permissions"; import { WL_SESSION_COOKIE, @@ -58,7 +56,8 @@ export type SiteAdminPermission = * Field session gate — /water (irrigator PIN). * * Reads the `wl_session` cookie, joins it to `water_sessions` and the - * linked `water_irrigators` row, and returns the user/brand/role. + * linked `field_workers` row, and returns the user/brand/role. + * (Cycle 10: workers are unified into `field_workers`.) * * Returns `{ ok: false, error }` for: * - missing cookie → "Not logged in" @@ -79,12 +78,12 @@ export async function requireFieldSession(): Promise { const rows = await db .select({ session: waterSessions, - irrigator: waterIrrigators, + worker: fieldWorkers, }) .from(waterSessions) .innerJoin( - waterIrrigators, - eq(waterIrrigators.id, waterSessions.irrigatorId), + fieldWorkers, + eq(fieldWorkers.id, waterSessions.fieldWorkerId), ) .where(eq(waterSessions.id, sessionId)) .limit(1); @@ -99,15 +98,18 @@ export async function requireFieldSession(): Promise { } return { ok: false as const, error: "Session expired" }; } - if (!row.irrigator.active) { + if (!row.worker.active) { return { ok: false as const, error: "User is inactive" }; } return { ok: true as const, - userId: row.irrigator.id, - brandId: row.irrigator.brandId, - role: - (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator", + userId: row.worker.id, + brandId: row.worker.brandId, + // Cycle 10: workers unified, but a row reached via the water_sessions + // FK is by construction a water-domain worker (`irrigator` or + // `water_admin`). Narrow at the join boundary so FieldSessionAuthed + // remains a tight union. + role: row.worker.role as "irrigator" | "water_admin", }; }); } @@ -121,18 +123,18 @@ export async function getFieldSessionUser(): Promise<{ userId: string; name: string; brandId: string; - role: "irrigator" | "water_admin"; + role: FieldWorkerRole; } | null> { const s = await requireFieldSession(); if (!s.ok) return null; return withPlatformAdmin(async (db) => { const rows = await db .select({ - name: waterIrrigators.name, - role: waterIrrigators.role, + name: fieldWorkers.name, + role: fieldWorkers.role, }) - .from(waterIrrigators) - .where(eq(waterIrrigators.id, s.userId)) + .from(fieldWorkers) + .where(eq(fieldWorkers.id, s.userId)) .limit(1); const row = rows[0]; if (!row) return null; @@ -140,7 +142,7 @@ export async function getFieldSessionUser(): Promise<{ userId: s.userId, name: row.name, brandId: s.brandId, - role: (row.role as "irrigator" | "water_admin") ?? "irrigator", + role: row.role, }; }); } diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index 9a4541a..b8c6c6b 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -23,11 +23,11 @@ import { and, desc, eq, gte, sql } from "drizzle-orm"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { waterHeadgates, - waterIrrigators, waterSessions, waterLogEntries, waterAdminSessions, } from "@/db/schema/water-log"; +import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers"; import { verifyPin, validatePin } from "@/lib/water-log-pin"; import { logAlert } from "@/lib/water-log-audit"; import { @@ -85,7 +85,7 @@ type VerifyPinResult = success: true; user_id: string; name: string; - role: "irrigator" | "water_admin"; + role: FieldWorkerRole; session_id: string; lang: string; } @@ -163,13 +163,16 @@ export async function verifyWaterPin( const formatError = validatePin(pin); if (formatError) return { success: false, error: formatError }; - // Look the irrigator up across all brands (PIN is the only credential). + // Look the worker up across all brands (PIN is the only credential). + // Cycle 10: scans `field_workers` (the unified worker table from + // `db/schema/field-workers.ts`). Both water and time workers live here + // now; the role determines what they can do (water_admin vs worker). const lookup = await withPlatformAdmin(async (db) => { const rows = await db .select() - .from(waterIrrigators) - .where(eq(waterIrrigators.active, true)) - .orderBy(waterIrrigators.name); + .from(fieldWorkers) + .where(eq(fieldWorkers.active, true)) + .orderBy(fieldWorkers.name); return rows; }); @@ -203,16 +206,16 @@ export async function verifyWaterPin( const [inserted] = await db .insert(waterSessions) .values({ - irrigatorId: match.id, + fieldWorkerId: match.id, expiresAt, }) .returning({ id: waterSessions.id }); if (!inserted) throw new Error("Failed to create session"); await db - .update(waterIrrigators) + .update(fieldWorkers) .set({ lastUsedAt: new Date() }) - .where(eq(waterIrrigators.id, match.id)); + .where(eq(fieldWorkers.id, match.id)); return inserted.id; }); @@ -227,7 +230,7 @@ export async function verifyWaterPin( success: true, user_id: match.id, name: match.name, - role: (match.role as "irrigator" | "water_admin") ?? "irrigator", + role: match.role, session_id: sessionId, lang: match.languagePreference, }; @@ -292,7 +295,7 @@ export async function submitWaterEntry( .values({ brandId: session.brandId, headgateId, - irrigatorId: session.userId, + fieldWorkerId: session.userId, measurement: measurement.toString(), unit, totalGallons: totalGallons?.toString() ?? null, diff --git a/src/services/smartsheet-sync.ts b/src/services/smartsheet-sync.ts index 48c0821..69b3750 100644 --- a/src/services/smartsheet-sync.ts +++ b/src/services/smartsheet-sync.ts @@ -33,12 +33,12 @@ import { withBrand } from "@/db/client"; import { waterLogEntries, waterHeadgates, - waterIrrigators, waterSmartsheetConfig, waterSmartsheetSyncQueue, waterSmartsheetSyncLog, type SmartsheetColumnMapping, } from "@/db/schema/water-log"; +import { fieldWorkers } from "@/db/schema/field-workers"; import { resolveWorkspaceToken } from "@/services/workspace-token"; import { addRow, @@ -101,7 +101,7 @@ export async function syncEntryToSmartsheet( .select({ entry: waterLogEntries, headgateName: waterHeadgates.name, - irrigatorName: waterIrrigators.name, + irrigatorName: fieldWorkers.name, }) .from(waterLogEntries) .leftJoin( @@ -109,8 +109,8 @@ export async function syncEntryToSmartsheet( eq(waterHeadgates.id, waterLogEntries.headgateId), ) .leftJoin( - waterIrrigators, - eq(waterIrrigators.id, waterLogEntries.irrigatorId), + fieldWorkers, + eq(fieldWorkers.id, waterLogEntries.fieldWorkerId), ) .where(eq(waterLogEntries.id, entryId)) .limit(1), diff --git a/src/services/time-tracking-smartsheet-sync.ts b/src/services/time-tracking-smartsheet-sync.ts index 577d497..f70ef9e 100644 --- a/src/services/time-tracking-smartsheet-sync.ts +++ b/src/services/time-tracking-smartsheet-sync.ts @@ -35,13 +35,13 @@ import { and, eq, inArray, lte } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { timeTrackingLogs, - timeTrackingWorkers, timeTrackingTasks, timeTrackingSmartsheetConfig, timeTrackingSmartsheetSyncQueue, timeTrackingSmartsheetSyncLog, type TTSmartsheetColumnMapping, } from "@/db/schema/time-tracking"; +import { fieldWorkers } from "@/db/schema/field-workers"; import { resolveWorkspaceToken } from "@/services/workspace-token"; import { addRow, @@ -182,12 +182,12 @@ export async function syncLogToSmartsheet( db .select({ log: timeTrackingLogs, - workerName: timeTrackingWorkers.name, + workerName: fieldWorkers.name, }) .from(timeTrackingLogs) .leftJoin( - timeTrackingWorkers, - eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), + fieldWorkers, + eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId), ) .where(eq(timeTrackingLogs.id, logId)) .limit(1), diff --git a/tests/unit/water-log-auth.test.ts b/tests/unit/water-log-auth.test.ts index 45a5b80..cd36ca2 100644 --- a/tests/unit/water-log-auth.test.ts +++ b/tests/unit/water-log-auth.test.ts @@ -150,7 +150,7 @@ describe("requireFieldSession()", () => { limit: async () => [ { session: { id: "expired-id", expiresAt: past }, - irrigator: { + worker: { id: "u1", brandId: "b1", role: "irrigator", @@ -185,7 +185,7 @@ describe("requireFieldSession()", () => { limit: async () => [ { session: { id: "valid-id", expiresAt: future }, - irrigator: { + worker: { id: "u1", brandId: "b1", role: "irrigator", @@ -214,7 +214,7 @@ describe("requireFieldSession()", () => { limit: async () => [ { session: { id: "valid-id", expiresAt: future }, - irrigator: { + worker: { id: "u1", brandId: "b1", role: "irrigator", diff --git a/vitest.config.ts b/vitest.config.ts index df83d21..9c590ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ { find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") }, { find: "@/db/schema/smartsheet-workspace", replacement: path.resolve(__dirname, "db/schema/smartsheet-workspace.ts") }, { find: "@/db/schema/time-tracking", replacement: path.resolve(__dirname, "db/schema/time-tracking.ts") }, + { find: "@/db/schema/field-workers", replacement: path.resolve(__dirname, "db/schema/field-workers.ts") }, { find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") }, { find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") }, { find: "@/db", replacement: path.resolve(__dirname, "db") },