feat(workers): unify water + time worker tables into field_workers (cycle 10)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.
Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
IN ('worker','time_admin','irrigator','water_admin'),
language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
SET NOT NULL
- drops the two old tables and renames FK columns
(irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
field_worker_id
Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.
Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
with zero NULL field_worker_id, index rebuilt.
PR-reviewer: APPROVED.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Cycle 10 — Unified field worker table.
|
||||
*
|
||||
* Replaces `water_irrigators` + `time_tracking_workers` (one row per
|
||||
* person with two independent PIN hashes) with one row, one PIN hash,
|
||||
* one role vocabulary.
|
||||
*
|
||||
* Role vocabulary (matches the CHECK constraint in
|
||||
* `db/migrations/0097_field_workers.sql`):
|
||||
* - `worker` — default; can submit water entries + clock in/out
|
||||
* - `time_admin` — can manage time-tracking tasks, settings, and
|
||||
* other time workers
|
||||
* - `irrigator` — legacy role from `water_irrigators`; same powers
|
||||
* as `worker` but kept distinct for UI filtering
|
||||
* (the water admin UI shows this label)
|
||||
* - `water_admin` — can manage headgates, water workers, and water
|
||||
* entries
|
||||
*
|
||||
* The PIN is a scrypt self-describing hash from `@/lib/water-log-pin`
|
||||
* (column name `pin_hash`, NOT `pin` — see cycle 10 migration for the
|
||||
* rename).
|
||||
*/
|
||||
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
|
||||
export const fieldWorkers = pgTable(
|
||||
"field_workers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
pinHash: text("pin_hash").notNull(),
|
||||
role: text("role", {
|
||||
enum: ["worker", "time_admin", "irrigator", "water_admin"],
|
||||
})
|
||||
.notNull()
|
||||
.default("worker"),
|
||||
languagePreference: text("language_preference").notNull().default("en"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("field_workers_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type FieldWorker = typeof fieldWorkers.$inferSelect;
|
||||
export type NewFieldWorker = typeof fieldWorkers.$inferInsert;
|
||||
export type FieldWorkerRole = FieldWorker["role"];
|
||||
@@ -14,6 +14,7 @@ export * from "./water-log";
|
||||
export * from "./communications";
|
||||
export * from "./marketing";
|
||||
export * from "./time-tracking";
|
||||
export * from "./field-workers";
|
||||
export * from "./shipping";
|
||||
export * from "./support";
|
||||
export * from "./files";
|
||||
+26
-29
@@ -5,6 +5,13 @@
|
||||
* Cycle 7: the encrypted token columns on
|
||||
* `time_tracking_smartsheet_config` moved to `smartsheet_workspace`
|
||||
* (see `db/schema/smartsheet-workspace.ts`).
|
||||
*
|
||||
* Cycle 10: the `time_tracking_workers` table was DROPPED in
|
||||
* `db/migrations/0097_field_workers.sql`. Worker records now live
|
||||
* in `field_workers` (see `db/schema/field-workers.ts`). The
|
||||
* `worker_id` column on the downstream tables
|
||||
* (`time_tracking_logs`, `time_tracking_notification_log`) was
|
||||
* renamed to `field_worker_id` and the FK repointed.
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -18,6 +25,7 @@ import {
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
export const timeTrackingSettings = pgTable(
|
||||
"time_tracking_settings",
|
||||
@@ -55,29 +63,12 @@ export const timeTrackingSettings = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
export const timeTrackingWorkers = pgTable(
|
||||
"time_tracking_workers",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
role: text("role", { enum: ["worker", "time_admin"] })
|
||||
.notNull()
|
||||
.default("worker"),
|
||||
lang: text("lang").notNull().default("en"),
|
||||
pin: text("pin").notNull(),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("time_tracking_workers_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
// Cycle 10: `time_tracking_workers` table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in `field_workers`
|
||||
// (see `db/schema/field-workers.ts`). Any code that previously read
|
||||
// from timeTrackingWorkers should now read from fieldWorkers and
|
||||
// filter by role = 'worker' or 'time_admin' if it needs time-only
|
||||
// workers.
|
||||
|
||||
export const timeTrackingTasks = pgTable(
|
||||
"time_tracking_tasks",
|
||||
@@ -109,9 +100,10 @@ export const timeTrackingLogs = pgTable(
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
workerId: uuid("worker_id")
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => timeTrackingWorkers.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
taskId: uuid("task_id").references(() => timeTrackingTasks.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
@@ -129,7 +121,9 @@ export const timeTrackingLogs = pgTable(
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("time_tracking_logs_brand_idx").on(t.brandId),
|
||||
workerIdx: index("time_tracking_logs_worker_idx").on(t.workerId),
|
||||
fieldWorkerIdx: index("time_tracking_logs_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
),
|
||||
clockInIdx: index("time_tracking_logs_clock_in_idx").on(t.clockIn),
|
||||
}),
|
||||
);
|
||||
@@ -141,8 +135,9 @@ export const timeTrackingNotificationLog = pgTable(
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
workerId: uuid("worker_id").references(
|
||||
() => timeTrackingWorkers.id,
|
||||
/** Cycle 10: was `worker_id` (FK → time_tracking_workers); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id").references(
|
||||
() => fieldWorkers.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
notificationType: text("notification_type").notNull(),
|
||||
@@ -160,7 +155,9 @@ export const timeTrackingNotificationLog = pgTable(
|
||||
);
|
||||
|
||||
export type TimeTrackingSettings = typeof timeTrackingSettings.$inferSelect;
|
||||
export type TimeTrackingWorker = typeof timeTrackingWorkers.$inferSelect;
|
||||
// Cycle 10: `TimeTrackingWorker` was removed. Use `FieldWorker` from
|
||||
// `./field-workers` instead; filter by role = 'worker' or 'time_admin'
|
||||
// if you need time-only workers.
|
||||
export type TimeTrackingTask = typeof timeTrackingTasks.$inferSelect;
|
||||
export type TimeTrackingLog = typeof timeTrackingLogs.$inferSelect;
|
||||
export type TimeTrackingNotificationLog =
|
||||
|
||||
+20
-35
@@ -3,7 +3,8 @@
|
||||
*
|
||||
* Six tables, all brand-scoped with RLS:
|
||||
* - water_headgates — physical gates a measurement is tied to
|
||||
* - water_irrigators — PIN-authenticated field workers
|
||||
* - field_workers — unified worker table (cycle 10; replaces
|
||||
* water_irrigators + time_tracking_workers)
|
||||
* - water_sessions — short-lived PIN sessions for irrigators
|
||||
* - water_log_entries — the actual reading logs
|
||||
* - water_alert_log — high/low threshold alert history
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
import { fieldWorkers } from "./field-workers";
|
||||
|
||||
// Cycle 7: the `bytea` customType used to live here for the
|
||||
// encrypted Smartsheet token columns. Those columns moved to
|
||||
@@ -66,40 +68,21 @@ export const waterHeadgates = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterIrrigators = pgTable(
|
||||
"water_irrigators",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
pinHash: text("pin_hash").notNull(),
|
||||
languagePreference: text("language_preference")
|
||||
.notNull()
|
||||
.default("en"),
|
||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||
role: text("role").notNull().default("irrigator"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
// Cycle 10: water_irrigators table was DROPPED in
|
||||
// 0097_field_workers.sql. Worker records now live in
|
||||
// `field_workers` (see `db/schema/field-workers.ts`).
|
||||
// Any code that previously read from waterIrrigators should now
|
||||
// read from fieldWorkers and filter by role = 'irrigator' or
|
||||
// 'water_admin' if it needs water-only workers.
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
"water_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
@@ -117,9 +100,10 @@ export const waterLogEntries = pgTable(
|
||||
headgateId: uuid("headgate_id")
|
||||
.notNull()
|
||||
.references(() => waterHeadgates.id, { onDelete: "cascade" }),
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
/** Cycle 10: was `irrigator_id` (FK → water_irrigators); now unified. */
|
||||
fieldWorkerId: uuid("field_worker_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
.references(() => fieldWorkers.id, { onDelete: "cascade" }),
|
||||
/** Raw measurement value, in the entry's `unit`. */
|
||||
measurement: numeric("measurement").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
@@ -146,8 +130,8 @@ export const waterLogEntries = pgTable(
|
||||
t.brandId,
|
||||
t.loggedDate,
|
||||
),
|
||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||
t.irrigatorId,
|
||||
fieldWorkerIdx: index("water_log_entries_field_worker_idx").on(
|
||||
t.fieldWorkerId,
|
||||
t.loggedAt,
|
||||
),
|
||||
}),
|
||||
@@ -248,8 +232,9 @@ export const waterAuditLog = pgTable(
|
||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||
|
||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||
// Cycle 10: `WaterIrrigator` / `WaterIrrigatorInsert` were removed.
|
||||
// Use `FieldWorker` from `./field-workers` instead; filter by role =
|
||||
// 'irrigator' or 'water_admin' if you need water-only workers.
|
||||
|
||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TimeTrackingSession | nu
|
||||
return withPlatformAdmin(async (db) => {
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function checkAndNotifyOvertime(
|
||||
.insert(timeTrackingNotificationLog)
|
||||
.values({
|
||||
brandId,
|
||||
workerId,
|
||||
fieldWorkerId: workerId,
|
||||
notificationType: trigger,
|
||||
recipient: "admin@pending",
|
||||
subject,
|
||||
|
||||
@@ -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<FieldWorkerRole, "irrigator" | "water_admin">;
|
||||
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<string | nu
|
||||
});
|
||||
}
|
||||
|
||||
async function withPlatformAdminBrandOfIrrigator(userId: string): Promise<string | null> {
|
||||
async function withPlatformAdminBrandOfFieldWorker(userId: string): Promise<string | null> {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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<FieldSessionResult> {
|
||||
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<FieldSessionResult> {
|
||||
}
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") },
|
||||
|
||||
Reference in New Issue
Block a user