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
+322
View File
@@ -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;
+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 "./communications";
export * from "./marketing"; export * from "./marketing";
export * from "./time-tracking"; export * from "./time-tracking";
export * from "./field-workers";
export * from "./shipping"; export * from "./shipping";
export * from "./support"; export * from "./support";
export * from "./files"; export * from "./files";
+26 -29
View File
@@ -5,6 +5,13 @@
* Cycle 7: the encrypted token columns on * Cycle 7: the encrypted token columns on
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace` * `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
* (see `db/schema/smartsheet-workspace.ts`). * (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 { import {
pgTable, pgTable,
@@ -18,6 +25,7 @@ import {
jsonb, jsonb,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
import { fieldWorkers } from "./field-workers";
export const timeTrackingSettings = pgTable( export const timeTrackingSettings = pgTable(
"time_tracking_settings", "time_tracking_settings",
@@ -55,29 +63,12 @@ export const timeTrackingSettings = pgTable(
}, },
); );
export const timeTrackingWorkers = pgTable( // Cycle 10: `time_tracking_workers` table was DROPPED in
"time_tracking_workers", // 0097_field_workers.sql. Worker records now live in `field_workers`
{ // (see `db/schema/field-workers.ts`). Any code that previously read
id: uuid("id").primaryKey().defaultRandom(), // from timeTrackingWorkers should now read from fieldWorkers and
brandId: uuid("brand_id") // filter by role = 'worker' or 'time_admin' if it needs time-only
.notNull() // workers.
.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),
}),
);
export const timeTrackingTasks = pgTable( export const timeTrackingTasks = pgTable(
"time_tracking_tasks", "time_tracking_tasks",
@@ -109,9 +100,10 @@ export const timeTrackingLogs = pgTable(
brandId: uuid("brand_id") brandId: uuid("brand_id")
.notNull() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .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() .notNull()
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }), .references(() => fieldWorkers.id, { onDelete: "cascade" }),
taskId: uuid("task_id").references(() => timeTrackingTasks.id, { taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
onDelete: "set null", onDelete: "set null",
}), }),
@@ -129,7 +121,9 @@ export const timeTrackingLogs = pgTable(
}, },
(t) => ({ (t) => ({
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId), 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), clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
}), }),
); );
@@ -141,8 +135,9 @@ export const timeTrackingNotificationLog = pgTable(
brandId: uuid("brand_id") brandId: uuid("brand_id")
.notNull() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
workerId: uuid("worker_id").references( /** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
() => timeTrackingWorkers.id, fieldWorkerId: uuid("field_worker_id").references(
() => fieldWorkers.id,
{ onDelete: "set null" }, { onDelete: "set null" },
), ),
notificationType: text("notification_type").notNull(), notificationType: text("notification_type").notNull(),
@@ -160,7 +155,9 @@ export const timeTrackingNotificationLog = pgTable(
); );
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect; 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 TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect; export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
export type TimeTrackingNotificationLog = export type TimeTrackingNotificationLog =
+20 -35
View File
@@ -3,7 +3,8 @@
* *
* Six tables, all brand-scoped with RLS: * Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to * - 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_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs * - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history * - water_alert_log — high/low threshold alert history
@@ -27,6 +28,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
import { adminUsers } from "./brands"; import { adminUsers } from "./brands";
import { fieldWorkers } from "./field-workers";
// Cycle 7: the `bytea` customType used to live here for the // Cycle 7: the `bytea` customType used to live here for the
// encrypted Smartsheet token columns. Those columns moved to // encrypted Smartsheet token columns. Those columns moved to
@@ -66,40 +68,21 @@ export const waterHeadgates = pgTable(
}), }),
); );
export const waterIrrigators = pgTable( // Cycle 10: water_irrigators table was DROPPED in
"water_irrigators", // 0097_field_workers.sql. Worker records now live in
{ // `field_workers` (see `db/schema/field-workers.ts`).
id: uuid("id").primaryKey().defaultRandom(), // Any code that previously read from waterIrrigators should now
brandId: uuid("brand_id") // read from fieldWorkers and filter by role = 'irrigator' or
.notNull() // 'water_admin' if it needs water-only workers.
.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),
}),
);
export const waterSessions = pgTable( export const waterSessions = pgTable(
"water_sessions", "water_sessions",
{ {
id: uuid("id").primaryKey().defaultRandom(), 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() .notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }), .references(() => fieldWorkers.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
@@ -117,9 +100,10 @@ export const waterLogEntries = pgTable(
headgateId: uuid("headgate_id") headgateId: uuid("headgate_id")
.notNull() .notNull()
.references(() => waterHeadgates.id, { onDelete: "cascade" }), .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() .notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }), .references(() => fieldWorkers.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */ /** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(), measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(), unit: text("unit").notNull(),
@@ -146,8 +130,8 @@ export const waterLogEntries = pgTable(
t.brandId, t.brandId,
t.loggedDate, t.loggedDate,
), ),
irrigatorIdx: index("water_log_entries_irrigator_idx").on( fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
t.irrigatorId, t.fieldWorkerId,
t.loggedAt, t.loggedAt,
), ),
}), }),
@@ -248,8 +232,9 @@ export const waterAuditLog = pgTable(
export type WaterHeadgate = typeof waterHeadgates.$inferSelect; export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert; export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect; // Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert; // 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 WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect; export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
+15 -12
View File
@@ -307,13 +307,23 @@ ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
-- 11. Time tracking infrastructure + logs -- 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 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 FROM brands b
CROSS JOIN generate_series(1, 5) AS i CROSS JOIN generate_series(1, 5) AS i
WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING; 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) 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 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 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') WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING; 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 SELECT
b.id, 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), (SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1),
'Task ' || (1 + (i % 8)), 'Task ' || (1 + (i % 8)),
(NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval), (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') WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO water_irrigators (id, brand_id, name, pin_hash, language_preference, role, phone, active) INSERT INTO water_log_entries (brand_id, headgate_id, field_worker_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes)
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)
SELECT SELECT
b.id, b.id,
(SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1), (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, (50 + (i * 7) % 200)::numeric,
'GPM', 'GPM',
NOW() - ((i % 14) || ' days')::interval, NOW() - ((i % 14) || ' days')::interval,
+21 -17
View File
@@ -21,15 +21,15 @@
"use server"; "use server";
import { cookies } from "next/headers"; 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 { randomUUID } from "node:crypto";
import { withBrand, withPlatformAdmin } from "@/db/client"; import { withBrand, withPlatformAdmin } from "@/db/client";
import { import {
timeTrackingWorkers,
timeTrackingTasks, timeTrackingTasks,
timeTrackingLogs, timeTrackingLogs,
timeTrackingSmartsheetSyncQueue, timeTrackingSmartsheetSyncQueue,
} from "@/db/schema/time-tracking"; } from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { verifyPin } from "@/lib/water-log-pin"; import { verifyPin } from "@/lib/water-log-pin";
import { getWorkerPeriodTotals } from "./index"; 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 // 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 // a small worker pool this is fine; if it grows we could index a
// lookup table, but per-brand counts are typically <100. // 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) => { const rows = await withBrand(brandId, async (db) => {
return db return db
.select() .select()
.from(timeTrackingWorkers) .from(fieldWorkers)
.where( .where(
and( and(
eq(timeTrackingWorkers.brandId, brandId), eq(fieldWorkers.brandId, brandId),
eq(timeTrackingWorkers.active, true), 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) { for (const r of rows) {
if (verifyPin(pin, r.pin)) { if (verifyPin(pin, r.pinHash)) {
match = r; match = r;
break; break;
} }
@@ -118,9 +122,9 @@ export async function verifyTimeTrackingPin(
// Bump lastUsedAt + set cookie. // Bump lastUsedAt + set cookie.
await withBrand(brandId, async (db) => { await withBrand(brandId, async (db) => {
await db await db
.update(timeTrackingWorkers) .update(fieldWorkers)
.set({ lastUsedAt: new Date() }) .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); const expiresAt = new Date(Date.now() + COOKIE_MAX_AGE * 1000);
@@ -128,7 +132,7 @@ export async function verifyTimeTrackingPin(
worker_id: match.id, worker_id: match.id,
name: match.name, name: match.name,
role: match.role, role: match.role,
lang: match.lang, lang: match.languagePreference,
session_id: randomUUID(), session_id: randomUUID(),
expires_at: expiresAt.toISOString(), expires_at: expiresAt.toISOString(),
brand_id: brandId, brand_id: brandId,
@@ -171,7 +175,7 @@ export async function clockInWorker(
.where( .where(
and( and(
eq(timeTrackingLogs.brandId, brandId), eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id), eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut), isNull(timeTrackingLogs.clockOut),
), ),
) )
@@ -200,7 +204,7 @@ export async function clockInWorker(
.insert(timeTrackingLogs) .insert(timeTrackingLogs)
.values({ .values({
brandId, brandId,
workerId: session.worker_id, fieldWorkerId: session.worker_id,
taskId: resolvedTaskId, taskId: resolvedTaskId,
taskName: resolvedTaskName, taskName: resolvedTaskName,
clockIn: now, clockIn: now,
@@ -280,7 +284,7 @@ export async function clockOutWorker(
.where( .where(
and( and(
eq(timeTrackingLogs.brandId, session.brand_id), eq(timeTrackingLogs.brandId, session.brand_id),
eq(timeTrackingLogs.workerId, session.worker_id), eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut), isNull(timeTrackingLogs.clockOut),
), ),
) )
@@ -347,7 +351,7 @@ export async function getOpenClockIn(brandId: string): Promise<{
.where( .where(
and( and(
eq(timeTrackingLogs.brandId, brandId), eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, session.worker_id), eq(timeTrackingLogs.fieldWorkerId, session.worker_id),
isNull(timeTrackingLogs.clockOut), isNull(timeTrackingLogs.clockOut),
), ),
) )
@@ -390,15 +394,15 @@ export async function getTimeTrackingSession(): Promise<TimeTrackingSession | nu
return withPlatformAdmin(async (db) => { return withPlatformAdmin(async (db) => {
const [worker] = await db const [worker] = await db
.select() .select()
.from(timeTrackingWorkers) .from(fieldWorkers)
.where(eq(timeTrackingWorkers.id, parsed.worker_id)) .where(eq(fieldWorkers.id, parsed.worker_id))
.limit(1); .limit(1);
if (!worker || !worker.active) return null; if (!worker || !worker.active) return null;
return { return {
worker_id: worker.id, worker_id: worker.id,
name: worker.name, name: worker.name,
role: worker.role, role: worker.role,
lang: worker.lang, lang: worker.languagePreference,
session_id: parsed.session_id, session_id: parsed.session_id,
expires_at: parsed.expires_at, expires_at: parsed.expires_at,
brand_id: worker.brandId, brand_id: worker.brandId,
+48 -41
View File
@@ -9,24 +9,23 @@
* `platform_admin` or brand-scoped permission. Reads require a brand * `platform_admin` or brand-scoped permission. Reads require a brand
* context. * context.
* *
* PIN storage: the `time_tracking_workers.pin` column is a TEXT but we * PIN storage: cycle 10 unified the worker tables into `field_workers`
* store a scrypt hash in it (self-describing format from * where `pin_hash` (TEXT) carries the scrypt hash (self-describing
* `@/lib/water-log-pin`). Plaintext PINs are returned ONCE on * format from `@/lib/water-log-pin`). Plaintext PINs are returned
* create/reset and never persisted. Follow-up migration could rename * ONCE on create/reset and never persisted.
* the column to `pin_hash`.
*/ */
"use server"; "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 { getAdminUser } from "@/lib/admin-permissions";
import { withBrand, withPlatformAdmin } from "@/db/client"; import { withBrand, withPlatformAdmin } from "@/db/client";
import { import {
timeTrackingWorkers,
timeTrackingTasks, timeTrackingTasks,
timeTrackingLogs, timeTrackingLogs,
timeTrackingSettings, timeTrackingSettings,
timeTrackingNotificationLog, timeTrackingNotificationLog,
} from "@/db/schema/time-tracking"; } from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { hashPin, generatePin } from "@/lib/water-log-pin"; import { hashPin, generatePin } from "@/lib/water-log-pin";
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
@@ -91,14 +90,16 @@ export type TimeSummary = {
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function rowToWorker(r: typeof timeTrackingWorkers.$inferSelect): TimeWorker { function rowToWorker(r: typeof fieldWorkers.$inferSelect): TimeWorker {
return { return {
id: r.id, id: r.id,
brand_id: r.brandId, brand_id: r.brandId,
name: r.name, name: r.name,
role: r.role, role: r.role,
lang: r.lang, // Cycle 10: column rename `lang` → `languagePreference` in field_workers.
pin: r.pin, lang: r.languagePreference,
// Cycle 10: column rename `pin` → `pinHash` in field_workers.
pin: r.pinHash,
active: r.active, active: r.active,
last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null, last_used_at: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
created_at: r.createdAt.toISOString(), created_at: r.createdAt.toISOString(),
@@ -127,9 +128,15 @@ export async function getTimeTrackingWorkers(
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
const rows = await db const rows = await db
.select() .select()
.from(timeTrackingWorkers) .from(fieldWorkers)
.where(eq(timeTrackingWorkers.brandId, brandId)) .where(
.orderBy(asc(timeTrackingWorkers.name)); 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); return rows.map(rowToWorker);
}); });
} }
@@ -159,13 +166,13 @@ export async function createTimeWorker(
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
const [row] = await db const [row] = await db
.insert(timeTrackingWorkers) .insert(fieldWorkers)
.values({ .values({
brandId, brandId,
name: trimmed, name: trimmed,
role, role,
lang, languagePreference: lang,
pin: pinHash, pinHash,
}) })
.returning(); .returning();
if (!row) return { success: false, error: "Insert returned no row" }; 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. // platform_admin (admins can reset across brands), then narrow.
const updated = await withPlatformAdmin(async (db) => { const updated = await withPlatformAdmin(async (db) => {
const [row] = await db const [row] = await db
.update(timeTrackingWorkers) .update(fieldWorkers)
.set({ pin: pinHash }) .set({ pinHash })
.where(eq(timeTrackingWorkers.id, workerId)) .where(eq(fieldWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id }); .returning({ id: fieldWorkers.id });
return row; return row;
}); });
@@ -215,10 +222,10 @@ export async function updateTimeWorker(
const result = await withPlatformAdmin(async (db) => { const result = await withPlatformAdmin(async (db) => {
const [row] = await db const [row] = await db
.update(timeTrackingWorkers) .update(fieldWorkers)
.set({ name: trimmed, role, lang, active }) .set({ name: trimmed, role, languagePreference: lang, active })
.where(eq(timeTrackingWorkers.id, workerId)) .where(eq(fieldWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id }); .returning({ id: fieldWorkers.id });
return row; return row;
}); });
@@ -234,9 +241,9 @@ export async function deleteTimeWorker(
const result = await withPlatformAdmin(async (db) => { const result = await withPlatformAdmin(async (db) => {
const [row] = await db const [row] = await db
.delete(timeTrackingWorkers) .delete(fieldWorkers)
.where(eq(timeTrackingWorkers.id, workerId)) .where(eq(fieldWorkers.id, workerId))
.returning({ id: timeTrackingWorkers.id }); .returning({ id: fieldWorkers.id });
return row; return row;
}); });
@@ -368,7 +375,7 @@ export async function getWorkerTimeLogs(
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
const conditions = [eq(timeTrackingLogs.brandId, brandId)]; const conditions = [eq(timeTrackingLogs.brandId, brandId)];
if (workerId) if (workerId)
conditions.push(eq(timeTrackingLogs.workerId, workerId)); conditions.push(eq(timeTrackingLogs.fieldWorkerId, workerId));
if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId)); if (taskId) conditions.push(eq(timeTrackingLogs.taskId, taskId));
if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start))); if (start) conditions.push(gte(timeTrackingLogs.clockIn, new Date(start)));
if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end))); if (end) conditions.push(lte(timeTrackingLogs.clockIn, new Date(end)));
@@ -377,8 +384,8 @@ export async function getWorkerTimeLogs(
.select({ .select({
id: timeTrackingLogs.id, id: timeTrackingLogs.id,
brand_id: timeTrackingLogs.brandId, brand_id: timeTrackingLogs.brandId,
worker_id: timeTrackingLogs.workerId, worker_id: timeTrackingLogs.fieldWorkerId,
worker_name: timeTrackingWorkers.name, worker_name: fieldWorkers.name,
task_id: timeTrackingLogs.taskId, task_id: timeTrackingLogs.taskId,
task_name: timeTrackingLogs.taskName, task_name: timeTrackingLogs.taskName,
clock_in: timeTrackingLogs.clockIn, clock_in: timeTrackingLogs.clockIn,
@@ -390,8 +397,8 @@ export async function getWorkerTimeLogs(
}) })
.from(timeTrackingLogs) .from(timeTrackingLogs)
.leftJoin( .leftJoin(
timeTrackingWorkers, fieldWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
) )
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(timeTrackingLogs.clockIn)) .orderBy(desc(timeTrackingLogs.clockIn))
@@ -441,8 +448,8 @@ export async function getTimeTrackingSummary(
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
const logs = await db const logs = await db
.select({ .select({
workerId: timeTrackingLogs.workerId, workerId: timeTrackingLogs.fieldWorkerId,
workerName: timeTrackingWorkers.name, workerName: fieldWorkers.name,
taskId: timeTrackingLogs.taskId, taskId: timeTrackingLogs.taskId,
taskName: timeTrackingLogs.taskName, taskName: timeTrackingLogs.taskName,
clockIn: timeTrackingLogs.clockIn, clockIn: timeTrackingLogs.clockIn,
@@ -451,8 +458,8 @@ export async function getTimeTrackingSummary(
}) })
.from(timeTrackingLogs) .from(timeTrackingLogs)
.leftJoin( .leftJoin(
timeTrackingWorkers, fieldWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
) )
.where( .where(
and( and(
@@ -668,8 +675,8 @@ export async function getTimeTrackingNotificationLog(
const rows = await db const rows = await db
.select({ .select({
id: timeTrackingNotificationLog.id, id: timeTrackingNotificationLog.id,
workerId: timeTrackingNotificationLog.workerId, workerId: timeTrackingNotificationLog.fieldWorkerId,
workerName: timeTrackingWorkers.name, workerName: fieldWorkers.name,
notificationType: timeTrackingNotificationLog.notificationType, notificationType: timeTrackingNotificationLog.notificationType,
recipient: timeTrackingNotificationLog.recipient, recipient: timeTrackingNotificationLog.recipient,
status: timeTrackingNotificationLog.status, status: timeTrackingNotificationLog.status,
@@ -677,8 +684,8 @@ export async function getTimeTrackingNotificationLog(
}) })
.from(timeTrackingNotificationLog) .from(timeTrackingNotificationLog)
.leftJoin( .leftJoin(
timeTrackingWorkers, fieldWorkers,
eq(timeTrackingWorkers.id, timeTrackingNotificationLog.workerId), eq(fieldWorkers.id, timeTrackingNotificationLog.fieldWorkerId),
) )
.where(eq(timeTrackingNotificationLog.brandId, brandId)) .where(eq(timeTrackingNotificationLog.brandId, brandId))
.orderBy(desc(timeTrackingNotificationLog.createdAt)) .orderBy(desc(timeTrackingNotificationLog.createdAt))
@@ -741,7 +748,7 @@ export async function getWorkerPeriodTotals(
.where( .where(
and( and(
eq(timeTrackingLogs.brandId, brandId), eq(timeTrackingLogs.brandId, brandId),
eq(timeTrackingLogs.workerId, workerId), eq(timeTrackingLogs.fieldWorkerId, workerId),
gte(timeTrackingLogs.clockIn, startOfPeriod), gte(timeTrackingLogs.clockIn, startOfPeriod),
), ),
); );
+1 -1
View File
@@ -77,7 +77,7 @@ export async function checkAndNotifyOvertime(
.insert(timeTrackingNotificationLog) .insert(timeTrackingNotificationLog)
.values({ .values({
brandId, brandId,
workerId, fieldWorkerId: workerId,
notificationType: trigger, notificationType: trigger,
recipient: "admin@pending", recipient: "admin@pending",
subject, subject,
+63 -47
View File
@@ -20,12 +20,15 @@ import { and, desc, eq, sql } from "drizzle-orm";
import { withBrand } from "@/db/client"; import { withBrand } from "@/db/client";
import { import {
waterHeadgates, waterHeadgates,
waterIrrigators,
waterLogEntries, waterLogEntries,
type WaterHeadgate, type WaterHeadgate,
type WaterIrrigator,
type WaterLogEntry, type WaterLogEntry,
} from "@/db/schema/water-log"; } 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 { hashPin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit"; import { logAuditEvent } from "@/lib/water-log-audit";
import { import {
@@ -52,7 +55,10 @@ export type AdminHeadgate = {
export type AdminIrrigator = { export type AdminIrrigator = {
id: string; id: string;
name: 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<FieldWorkerRole, "irrigator" | "water_admin">;
active: boolean; active: boolean;
language_preference: string; language_preference: string;
phone: string | null; phone: string | null;
@@ -142,7 +148,7 @@ function mapHeadgate(h: WaterHeadgate): AdminHeadgate {
}; };
} }
function mapIrrigator(i: WaterIrrigator): AdminIrrigator { function mapIrrigator(i: FieldWorker): AdminIrrigator {
return { return {
id: i.id, id: i.id,
name: i.name, name: i.name,
@@ -162,7 +168,7 @@ function mapEntry(
return { return {
id: e.id, id: e.id,
headgate_id: e.headgateId, headgate_id: e.headgateId,
user_id: e.irrigatorId, user_id: e.fieldWorkerId,
headgate_name: e.headgate_name, headgate_name: e.headgate_name,
user_name: e.user_name, user_name: e.user_name,
measurement: Number(e.measurement), measurement: Number(e.measurement),
@@ -369,9 +375,15 @@ const auth = await requireWaterAdminPermission();
return withBrand(brandId, async (db) => { return withBrand(brandId, async (db) => {
const rows = await db const rows = await db
.select() .select()
.from(waterIrrigators) .from(fieldWorkers)
.where(eq(waterIrrigators.brandId, brandId)) .where(
.orderBy(desc(waterIrrigators.createdAt)); 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); return rows.map(mapIrrigator);
}); });
} }
@@ -405,12 +417,16 @@ const auth = await requireWaterAdminPermission();
const result = await withBrand(brandId, async (db) => { const result = await withBrand(brandId, async (db) => {
const existing = await db const existing = await db
.select({ id: waterIrrigators.id }) .select({ id: fieldWorkers.id })
.from(waterIrrigators) .from(fieldWorkers)
.where( .where(
and( and(
eq(waterIrrigators.brandId, brandId), eq(fieldWorkers.brandId, brandId),
eq(waterIrrigators.name, trimmed), // 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); .limit(1);
@@ -418,7 +434,7 @@ const auth = await requireWaterAdminPermission();
return { success: false as const, error: "A user with that name already exists" }; return { success: false as const, error: "A user with that name already exists" };
} }
const [row] = await db const [row] = await db
.insert(waterIrrigators) .insert(fieldWorkers)
.values({ .values({
brandId, brandId,
name: trimmed, name: trimmed,
@@ -455,7 +471,7 @@ return createWaterUser(brandId, name, "irrigator", language);
} }
export async function updateWaterIrrigator( export async function updateWaterIrrigator(
irrigatorId: string, fieldWorkerId: string,
name: string, name: string,
active: boolean, active: boolean,
language: string, language: string,
@@ -466,7 +482,7 @@ export async function updateWaterIrrigator(
const auth = await requireWaterAdminPermission(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error }; 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" }; if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => { return withBrand(brand, async (db) => {
const trimmed = name?.trim(); const trimmed = name?.trim();
@@ -475,7 +491,7 @@ const auth = await requireWaterAdminPermission();
return { success: false, error: "Invalid language" }; return { success: false, error: "Invalid language" };
} }
const updated = await db const updated = await db
.update(waterIrrigators) .update(fieldWorkers)
.set({ .set({
name: trimmed, name: trimmed,
active, active,
@@ -484,7 +500,7 @@ const auth = await requireWaterAdminPermission();
phone: phone ?? null, phone: phone ?? null,
notes: notes ?? null, notes: notes ?? null,
}) })
.where(eq(waterIrrigators.id, irrigatorId)) .where(eq(fieldWorkers.id, fieldWorkerId))
.returning(); .returning();
if (!updated[0]) return { success: false, error: "User not found" }; if (!updated[0]) return { success: false, error: "User not found" };
await logAuditEvent({ await logAuditEvent({
@@ -493,7 +509,7 @@ const auth = await requireWaterAdminPermission();
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "update", action: "update",
entityType: "user", entityType: "user",
entityId: irrigatorId, entityId: fieldWorkerId,
details: { name: trimmed, active, role, language }, details: { name: trimmed, active, role, language },
}); });
return { success: true }; return { success: true };
@@ -506,13 +522,13 @@ export async function deleteWaterUser(
const auth = await requireWaterAdminPermission(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error }; 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" }; if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => { return withBrand(brand, async (db) => {
const deleted = await db const deleted = await db
.delete(waterIrrigators) .delete(fieldWorkers)
.where(eq(waterIrrigators.id, userId)) .where(eq(fieldWorkers.id, userId))
.returning({ id: waterIrrigators.id }); .returning({ id: fieldWorkers.id });
if (!deleted[0]) return { success: false, error: "User not found" }; if (!deleted[0]) return { success: false, error: "User not found" };
await logAuditEvent({ await logAuditEvent({
brandId: brand, brandId: brand,
@@ -532,20 +548,20 @@ export async function resetWaterIrrigatorPin(
const auth = await requireWaterAdminPermission(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error }; 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" }; if (!brand) return { success: false, error: "User not found" };
const pin = generatePin(); const pin = generatePin();
const pinHash = hashPin(pin); const pinHash = hashPin(pin);
return withBrand(brand, async (db) => { return withBrand(brand, async (db) => {
const updated = await db const updated = await db
.update(waterIrrigators) .update(fieldWorkers)
.set({ pinHash }) .set({ pinHash })
.where(eq(waterIrrigators.id, userId)) .where(eq(fieldWorkers.id, userId))
.returning({ id: waterIrrigators.id }); .returning({ id: fieldWorkers.id });
if (!updated[0]) return { success: false, error: "User not found" }; if (!updated[0]) return { success: false, error: "User not found" };
// Invalidate existing sessions for safety // Invalidate existing sessions for safety
await db.execute( await db.execute(
sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`, sql`DELETE FROM water_sessions WHERE field_worker_id = ${userId}`,
); );
await logAuditEvent({ await logAuditEvent({
brandId: brand, brandId: brand,
@@ -573,7 +589,7 @@ const auth = await requireWaterAdminPermission();
const rows = await db.execute<{ const rows = await db.execute<{
id: string; id: string;
headgate_id: string; headgate_id: string;
irrigator_id: string; field_worker_id: string;
headgate_name: string; headgate_name: string;
user_name: string; user_name: string;
measurement: string; measurement: string;
@@ -587,15 +603,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null; logged_date: string | null;
}>(sql` }>(sql`
SELECT SELECT
e.id, e.headgate_id, e.irrigator_id, e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name, h.name AS headgate_name,
i.name AS user_name, fw.name AS user_name,
e.measurement::text, e.unit, e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via, e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id 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} WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC ORDER BY e.logged_at DESC
LIMIT ${safeLimit} LIMIT ${safeLimit}
@@ -603,7 +619,7 @@ const auth = await requireWaterAdminPermission();
return rows.rows.map((r) => ({ return rows.rows.map((r) => ({
id: r.id, id: r.id,
headgate_id: r.headgate_id, headgate_id: r.headgate_id,
user_id: r.irrigator_id, user_id: r.field_worker_id,
headgate_name: r.headgate_name, headgate_name: r.headgate_name,
user_name: r.user_name, user_name: r.user_name,
measurement: Number(r.measurement), measurement: Number(r.measurement),
@@ -629,7 +645,7 @@ const auth = await requireWaterAdminPermission();
const rows = await db.execute<{ const rows = await db.execute<{
id: string; id: string;
headgate_id: string; headgate_id: string;
irrigator_id: string; field_worker_id: string;
headgate_name: string; headgate_name: string;
user_name: string; user_name: string;
measurement: string; measurement: string;
@@ -643,15 +659,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null; logged_date: string | null;
}>(sql` }>(sql`
SELECT SELECT
e.id, e.headgate_id, e.irrigator_id, e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name, h.name AS headgate_name,
i.name AS user_name, fw.name AS user_name,
e.measurement::text, e.unit, e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via, e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id 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} WHERE e.id = ${entryId}
LIMIT 1 LIMIT 1
`); `);
@@ -660,7 +676,7 @@ const auth = await requireWaterAdminPermission();
return { return {
id: r.id, id: r.id,
headgate_id: r.headgate_id, headgate_id: r.headgate_id,
user_id: r.irrigator_id, user_id: r.field_worker_id,
headgate_name: r.headgate_name, headgate_name: r.headgate_name,
user_name: r.user_name, user_name: r.user_name,
measurement: Number(r.measurement), measurement: Number(r.measurement),
@@ -770,7 +786,7 @@ const auth = await requireWaterAdminPermission();
( (
SELECT i.name SELECT i.name
FROM water_log_entries e2 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 WHERE e2.headgate_id = h.id
ORDER BY e2.logged_at DESC ORDER BY e2.logged_at DESC
LIMIT 1 LIMIT 1
@@ -823,7 +839,7 @@ const auth = await requireWaterAdminPermission();
const recent = await db.execute<{ const recent = await db.execute<{
id: string; id: string;
headgate_id: string; headgate_id: string;
irrigator_id: string; field_worker_id: string;
headgate_name: string; headgate_name: string;
user_name: string; user_name: string;
measurement: string; measurement: string;
@@ -837,15 +853,15 @@ const auth = await requireWaterAdminPermission();
logged_date: string | null; logged_date: string | null;
}>(sql` }>(sql`
SELECT SELECT
e.id, e.headgate_id, e.irrigator_id, e.id, e.headgate_id, e.field_worker_id,
h.name AS headgate_name, h.name AS headgate_name,
i.name AS user_name, fw.name AS user_name,
e.measurement::text, e.unit, e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via, e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id 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} WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC ORDER BY e.logged_at DESC
LIMIT 10 LIMIT 10
@@ -858,7 +874,7 @@ const auth = await requireWaterAdminPermission();
recent_entries: recent.rows.map((r) => ({ recent_entries: recent.rows.map((r) => ({
id: r.id, id: r.id,
headgate_id: r.headgate_id, headgate_id: r.headgate_id,
user_id: r.irrigator_id, user_id: r.field_worker_id,
headgate_name: r.headgate_name, headgate_name: r.headgate_name,
user_name: r.user_name, user_name: r.user_name,
measurement: Number(r.measurement), measurement: Number(r.measurement),
@@ -944,13 +960,13 @@ async function withPlatformAdminBrandOf(headgateId: string): Promise<string | nu
}); });
} }
async function withPlatformAdminBrandOfIrrigator(userId: string): Promise<string | null> { async function withPlatformAdminBrandOfFieldWorker(userId: string): Promise<string | null> {
const { withPlatformAdmin } = await import("@/db/client"); const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => { return withPlatformAdmin(async (db) => {
const rows = await db const rows = await db
.select({ brandId: waterIrrigators.brandId }) .select({ brandId: fieldWorkers.brandId })
.from(waterIrrigators) .from(fieldWorkers)
.where(eq(waterIrrigators.id, userId)) .where(eq(fieldWorkers.id, userId))
.limit(1); .limit(1);
return rows[0]?.brandId ?? null; return rows[0]?.brandId ?? null;
}); });
+21 -19
View File
@@ -29,10 +29,8 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { withPlatformAdmin } from "@/db/client"; import { withPlatformAdmin } from "@/db/client";
import { import { waterSessions } from "@/db/schema/water-log";
waterSessions, import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
waterIrrigators,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { import {
WL_SESSION_COOKIE, WL_SESSION_COOKIE,
@@ -58,7 +56,8 @@ export type SiteAdminPermission =
* Field session gate — /water (irrigator PIN). * Field session gate — /water (irrigator PIN).
* *
* Reads the `wl_session` cookie, joins it to `water_sessions` and the * 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: * Returns `{ ok: false, error }` for:
* - missing cookie → "Not logged in" * - missing cookie → "Not logged in"
@@ -79,12 +78,12 @@ export async function requireFieldSession(): Promise<FieldSessionResult> {
const rows = await db const rows = await db
.select({ .select({
session: waterSessions, session: waterSessions,
irrigator: waterIrrigators, worker: fieldWorkers,
}) })
.from(waterSessions) .from(waterSessions)
.innerJoin( .innerJoin(
waterIrrigators, fieldWorkers,
eq(waterIrrigators.id, waterSessions.irrigatorId), eq(fieldWorkers.id, waterSessions.fieldWorkerId),
) )
.where(eq(waterSessions.id, sessionId)) .where(eq(waterSessions.id, sessionId))
.limit(1); .limit(1);
@@ -99,15 +98,18 @@ export async function requireFieldSession(): Promise<FieldSessionResult> {
} }
return { ok: false as const, error: "Session expired" }; 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: false as const, error: "User is inactive" };
} }
return { return {
ok: true as const, ok: true as const,
userId: row.irrigator.id, userId: row.worker.id,
brandId: row.irrigator.brandId, brandId: row.worker.brandId,
role: // Cycle 10: workers unified, but a row reached via the water_sessions
(row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator", // 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; userId: string;
name: string; name: string;
brandId: string; brandId: string;
role: "irrigator" | "water_admin"; role: FieldWorkerRole;
} | null> { } | null> {
const s = await requireFieldSession(); const s = await requireFieldSession();
if (!s.ok) return null; if (!s.ok) return null;
return withPlatformAdmin(async (db) => { return withPlatformAdmin(async (db) => {
const rows = await db const rows = await db
.select({ .select({
name: waterIrrigators.name, name: fieldWorkers.name,
role: waterIrrigators.role, role: fieldWorkers.role,
}) })
.from(waterIrrigators) .from(fieldWorkers)
.where(eq(waterIrrigators.id, s.userId)) .where(eq(fieldWorkers.id, s.userId))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
if (!row) return null; if (!row) return null;
@@ -140,7 +142,7 @@ export async function getFieldSessionUser(): Promise<{
userId: s.userId, userId: s.userId,
name: row.name, name: row.name,
brandId: s.brandId, brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator", role: row.role,
}; };
}); });
} }
+14 -11
View File
@@ -23,11 +23,11 @@ import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client"; import { withBrand, withPlatformAdmin } from "@/db/client";
import { import {
waterHeadgates, waterHeadgates,
waterIrrigators,
waterSessions, waterSessions,
waterLogEntries, waterLogEntries,
waterAdminSessions, waterAdminSessions,
} from "@/db/schema/water-log"; } from "@/db/schema/water-log";
import { fieldWorkers, type FieldWorkerRole } from "@/db/schema/field-workers";
import { verifyPin, validatePin } from "@/lib/water-log-pin"; import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit"; import { logAlert } from "@/lib/water-log-audit";
import { import {
@@ -85,7 +85,7 @@ type VerifyPinResult =
success: true; success: true;
user_id: string; user_id: string;
name: string; name: string;
role: "irrigator" | "water_admin"; role: FieldWorkerRole;
session_id: string; session_id: string;
lang: string; lang: string;
} }
@@ -163,13 +163,16 @@ export async function verifyWaterPin(
const formatError = validatePin(pin); const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError }; 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 lookup = await withPlatformAdmin(async (db) => {
const rows = await db const rows = await db
.select() .select()
.from(waterIrrigators) .from(fieldWorkers)
.where(eq(waterIrrigators.active, true)) .where(eq(fieldWorkers.active, true))
.orderBy(waterIrrigators.name); .orderBy(fieldWorkers.name);
return rows; return rows;
}); });
@@ -203,16 +206,16 @@ export async function verifyWaterPin(
const [inserted] = await db const [inserted] = await db
.insert(waterSessions) .insert(waterSessions)
.values({ .values({
irrigatorId: match.id, fieldWorkerId: match.id,
expiresAt, expiresAt,
}) })
.returning({ id: waterSessions.id }); .returning({ id: waterSessions.id });
if (!inserted) throw new Error("Failed to create session"); if (!inserted) throw new Error("Failed to create session");
await db await db
.update(waterIrrigators) .update(fieldWorkers)
.set({ lastUsedAt: new Date() }) .set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id)); .where(eq(fieldWorkers.id, match.id));
return inserted.id; return inserted.id;
}); });
@@ -227,7 +230,7 @@ export async function verifyWaterPin(
success: true, success: true,
user_id: match.id, user_id: match.id,
name: match.name, name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator", role: match.role,
session_id: sessionId, session_id: sessionId,
lang: match.languagePreference, lang: match.languagePreference,
}; };
@@ -292,7 +295,7 @@ export async function submitWaterEntry(
.values({ .values({
brandId: session.brandId, brandId: session.brandId,
headgateId, headgateId,
irrigatorId: session.userId, fieldWorkerId: session.userId,
measurement: measurement.toString(), measurement: measurement.toString(),
unit, unit,
totalGallons: totalGallons?.toString() ?? null, totalGallons: totalGallons?.toString() ?? null,
+4 -4
View File
@@ -33,12 +33,12 @@ import { withBrand } from "@/db/client";
import { import {
waterLogEntries, waterLogEntries,
waterHeadgates, waterHeadgates,
waterIrrigators,
waterSmartsheetConfig, waterSmartsheetConfig,
waterSmartsheetSyncQueue, waterSmartsheetSyncQueue,
waterSmartsheetSyncLog, waterSmartsheetSyncLog,
type SmartsheetColumnMapping, type SmartsheetColumnMapping,
} from "@/db/schema/water-log"; } from "@/db/schema/water-log";
import { fieldWorkers } from "@/db/schema/field-workers";
import { resolveWorkspaceToken } from "@/services/workspace-token"; import { resolveWorkspaceToken } from "@/services/workspace-token";
import { import {
addRow, addRow,
@@ -101,7 +101,7 @@ export async function syncEntryToSmartsheet(
.select({ .select({
entry: waterLogEntries, entry: waterLogEntries,
headgateName: waterHeadgates.name, headgateName: waterHeadgates.name,
irrigatorName: waterIrrigators.name, irrigatorName: fieldWorkers.name,
}) })
.from(waterLogEntries) .from(waterLogEntries)
.leftJoin( .leftJoin(
@@ -109,8 +109,8 @@ export async function syncEntryToSmartsheet(
eq(waterHeadgates.id, waterLogEntries.headgateId), eq(waterHeadgates.id, waterLogEntries.headgateId),
) )
.leftJoin( .leftJoin(
waterIrrigators, fieldWorkers,
eq(waterIrrigators.id, waterLogEntries.irrigatorId), eq(fieldWorkers.id, waterLogEntries.fieldWorkerId),
) )
.where(eq(waterLogEntries.id, entryId)) .where(eq(waterLogEntries.id, entryId))
.limit(1), .limit(1),
@@ -35,13 +35,13 @@ import { and, eq, inArray, lte } from "drizzle-orm";
import { withBrand } from "@/db/client"; import { withBrand } from "@/db/client";
import { import {
timeTrackingLogs, timeTrackingLogs,
timeTrackingWorkers,
timeTrackingTasks, timeTrackingTasks,
timeTrackingSmartsheetConfig, timeTrackingSmartsheetConfig,
timeTrackingSmartsheetSyncQueue, timeTrackingSmartsheetSyncQueue,
timeTrackingSmartsheetSyncLog, timeTrackingSmartsheetSyncLog,
type TTSmartsheetColumnMapping, type TTSmartsheetColumnMapping,
} from "@/db/schema/time-tracking"; } from "@/db/schema/time-tracking";
import { fieldWorkers } from "@/db/schema/field-workers";
import { resolveWorkspaceToken } from "@/services/workspace-token"; import { resolveWorkspaceToken } from "@/services/workspace-token";
import { import {
addRow, addRow,
@@ -182,12 +182,12 @@ export async function syncLogToSmartsheet(
db db
.select({ .select({
log: timeTrackingLogs, log: timeTrackingLogs,
workerName: timeTrackingWorkers.name, workerName: fieldWorkers.name,
}) })
.from(timeTrackingLogs) .from(timeTrackingLogs)
.leftJoin( .leftJoin(
timeTrackingWorkers, fieldWorkers,
eq(timeTrackingWorkers.id, timeTrackingLogs.workerId), eq(fieldWorkers.id, timeTrackingLogs.fieldWorkerId),
) )
.where(eq(timeTrackingLogs.id, logId)) .where(eq(timeTrackingLogs.id, logId))
.limit(1), .limit(1),
+3 -3
View File
@@ -150,7 +150,7 @@ describe("requireFieldSession()", () => {
limit: async () => [ limit: async () => [
{ {
session: { id: "expired-id", expiresAt: past }, session: { id: "expired-id", expiresAt: past },
irrigator: { worker: {
id: "u1", id: "u1",
brandId: "b1", brandId: "b1",
role: "irrigator", role: "irrigator",
@@ -185,7 +185,7 @@ describe("requireFieldSession()", () => {
limit: async () => [ limit: async () => [
{ {
session: { id: "valid-id", expiresAt: future }, session: { id: "valid-id", expiresAt: future },
irrigator: { worker: {
id: "u1", id: "u1",
brandId: "b1", brandId: "b1",
role: "irrigator", role: "irrigator",
@@ -214,7 +214,7 @@ describe("requireFieldSession()", () => {
limit: async () => [ limit: async () => [
{ {
session: { id: "valid-id", expiresAt: future }, session: { id: "valid-id", expiresAt: future },
irrigator: { worker: {
id: "u1", id: "u1",
brandId: "b1", brandId: "b1",
role: "irrigator", role: "irrigator",
+1
View File
@@ -11,6 +11,7 @@ export default defineConfig({
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") }, { 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/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/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/client", replacement: path.resolve(__dirname, "db/client.ts") },
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") }, { find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
{ find: "@/db", replacement: path.resolve(__dirname, "db") }, { find: "@/db", replacement: path.resolve(__dirname, "db") },