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