-- ============================================================================ -- 0098_time_tracking_timesheets.sql -- -- Time Tracking — Phase 2 (Timesheets, GPS, Approval/Lock, Audit) -- -- This migration extends the existing time-tracking schema with the -- production / payroll features the field app + admin UI need: -- -- 1. New roles on `field_workers` — `driver`, `supervisor` -- (kept distinct from `worker` so admin UIs can filter / grant -- approval rights without expanding the existing role semantics). -- -- 2. GPS verification on every clock-in / clock-out — -- `time_tracking_logs` gains lat/lng/accuracy columns for both -- events, plus a boolean `gps_verified` (false when the device -- denied geolocation, returned an error, or low-accuracy fix). -- -- 3. Manual time entries — -- `time_tracking_logs.entry_kind` becomes `'clock' | 'manual'`. -- Manual rows carry `manual_reason` (the why-this-was-added text -- required by payroll / labor law) and can be edited freely until -- the parent timesheet is locked. -- -- 4. Edit audit fields on `time_tracking_logs` — -- `edited_at`, `edited_by` (admin_user id), `edit_reason`. Filled -- by every edit that lands on a row; never cleared. -- -- 5. NEW: `time_tracking_timesheets` — one row per (worker, pay -- period). Statuses drive the approval workflow: -- draft → submitted → approved (locked) → unlocked (admin only, -- audit note required) -- → draft (after rejection) -- When `locked_at IS NOT NULL` the underlying logs become -- read-only to anyone except `platform_admin` / `time_admin`. -- -- 6. NEW: `time_tracking_audit_log` — append-only audit trail for -- every mutating action on a timesheet or its entries. Every -- server-action call that mutates a timesheet or a log row MUST -- write a row here in the same transaction. -- -- 7. NEW: `time_tracking_supervisor_assignments` — many-to-many -- (supervisor_worker_id, supervisee_worker_id). Supervisors only -- see / approve timesheets for their assigned crew. -- -- The migration is idempotent — every ALTER / CREATE uses IF NOT EXISTS -- or a DO block, so re-running on a partially-applied DB is safe. -- ============================================================================ -- ─────────────────────────────────────────────────────────────────────────── -- 1. Role enum extension -- ─────────────────────────────────────────────────────────────────────────── -- Existing enum (migration 0097) is named `field_workers_role_check` -- via a CHECK constraint, NOT a Postgres ENUM type. Verify by introspecting -- pg_constraint before adding new values. DO $$ DECLARE constraint_def TEXT; BEGIN -- Fetch the current CHECK expression SELECT pg_get_constraintdef(oid) INTO constraint_def FROM pg_constraint WHERE conname = 'field_workers_role_check'; IF constraint_def IS NOT NULL THEN -- Drop and recreate with the expanded vocabulary. The set is the union -- of all six role values the codebase now recognises. EXECUTE 'ALTER TABLE field_workers DROP CONSTRAINT field_workers_role_check'; END IF; END $$; ALTER TABLE field_workers ADD CONSTRAINT field_workers_role_check CHECK (role IN ('worker', 'time_admin', 'irrigator', 'water_admin', 'driver', 'supervisor')); -- Drop & recreate the Drizzle CHECK by hand if it exists under a different name -- (drizzle-kit may have generated `field_workers_role_check1`). DO $$ DECLARE cname TEXT; BEGIN FOR cname IN SELECT conname FROM pg_constraint WHERE conrelid = 'field_workers'::regclass AND contype = 'c' AND pg_get_constraintdef(oid) LIKE '%role%' AND conname <> 'field_workers_role_check' LOOP EXECUTE format('ALTER TABLE field_workers DROP CONSTRAINT %I', cname); END LOOP; END $$; -- ─────────────────────────────────────────────────────────────────────────── -- 2. GPS columns + entry_kind + manual_reason + edit audit on logs -- ─────────────────────────────────────────────────────────────────────────── ALTER TABLE time_tracking_logs ADD COLUMN IF NOT EXISTS clock_in_lat DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS clock_in_lng DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS clock_in_accuracy_m DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS clock_out_lat DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS clock_out_lng DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS clock_out_accuracy_m DOUBLE PRECISION, ADD COLUMN IF NOT EXISTS gps_verified BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS entry_kind TEXT NOT NULL DEFAULT 'clock', ADD COLUMN IF NOT EXISTS manual_reason TEXT, ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS edited_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS edit_reason TEXT; -- entry_kind CHECK (idempotent) DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'time_tracking_logs_entry_kind_check' ) THEN ALTER TABLE time_tracking_logs ADD CONSTRAINT time_tracking_logs_entry_kind_check CHECK (entry_kind IN ('clock', 'manual')); END IF; END $$; -- Index for fast "all manual entries this period" queries used by payroll CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_kind_idx ON time_tracking_logs (brand_id, entry_kind, clock_in); -- Index for the approval queue (entries that belong to a timesheet status) CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx ON time_tracking_logs (clock_in); -- ─────────────────────────────────────────────────────────────────────────── -- 3. time_tracking_timesheets (the workflow entity) -- ─────────────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS time_tracking_timesheets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, period_start DATE NOT NULL, period_end DATE NOT NULL, -- Workflow state status TEXT NOT NULL DEFAULT 'draft', -- Submission submitted_at TIMESTAMPTZ, submitted_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, submitted_notes TEXT, -- Approval (locks the timesheet) reviewed_at TIMESTAMPTZ, reviewed_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, reviewer_comment TEXT, -- Rejection (sends back to draft) rejected_at TIMESTAMPTZ, rejected_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, rejection_reason TEXT, -- Lock state (mirrors reviewed_at for fast filtering) locked_at TIMESTAMPTZ, locked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, -- Unlock state (rare, admin-only, MUST carry a note) unlocked_at TIMESTAMPTZ, unlocked_by UUID REFERENCES admin_users(id) ON DELETE SET NULL, unlock_audit_note TEXT NOT NULL DEFAULT '', -- Pre-computed totals — recomputed by triggers / RPCs, but cached here -- so the timesheet list view doesn't re-aggregate on every render. total_minutes INTEGER NOT NULL DEFAULT 0, regular_minutes INTEGER NOT NULL DEFAULT 0, overtime_minutes INTEGER NOT NULL DEFAULT 0, break_minutes INTEGER NOT NULL DEFAULT 0, entry_count INTEGER NOT NULL DEFAULT 0, -- Daily-OT flash cache: JSONB { 'YYYY-MM-DD': minutes } daily_totals JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT time_tracking_timesheets_period_check CHECK (period_end >= period_start), CONSTRAINT time_tracking_timesheets_status_check CHECK (status IN ('draft', 'submitted', 'approved', 'rejected')) ); CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_timesheets_worker_period_uniq ON time_tracking_timesheets (field_worker_id, period_start, period_end); CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_status_idx ON time_tracking_timesheets (brand_id, status, period_start DESC); CREATE INDEX IF NOT EXISTS time_tracking_timesheets_brand_worker_idx ON time_tracking_timesheets (brand_id, field_worker_id); -- ─────────────────────────────────────────────────────────────────────────── -- 4. time_tracking_audit_log (append-only) -- ─────────────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS time_tracking_audit_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, actor_id UUID REFERENCES admin_users(id) ON DELETE SET NULL, actor_label TEXT NOT NULL, actor_kind TEXT NOT NULL DEFAULT 'admin', action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id UUID, details JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS time_tracking_audit_log_brand_recent_idx ON time_tracking_audit_log (brand_id, created_at DESC); CREATE INDEX IF NOT EXISTS time_tracking_audit_log_entity_idx ON time_tracking_audit_log (entity_type, entity_id); -- ─────────────────────────────────────────────────────────────────────────── -- 5. Supervisor ↔ supervisee assignments -- ─────────────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS time_tracking_supervisor_assignments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE, supervisor_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, supervisee_field_worker_id UUID NOT NULL REFERENCES field_workers(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT time_tracking_supervisor_assignments_no_self CHECK (supervisor_field_worker_id <> supervisee_field_worker_id) ); CREATE UNIQUE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_uniq ON time_tracking_supervisor_assignments ( supervisor_field_worker_id, supervisee_field_worker_id ); CREATE INDEX IF NOT EXISTS time_tracking_supervisor_assignments_supervisor_idx ON time_tracking_supervisor_assignments (supervisor_field_worker_id); -- ─────────────────────────────────────────────────────────────────────────── -- 6. RLS — minimal, since the platform already relies on app-layer -- brand scoping via SECURITY DEFINER / Drizzle `withBrand` wrappers. -- The new tables are added to the same RLS scaffolding as the existing -- `time_tracking_*` tables when present; otherwise left open at the -- table level (the app enforces isolation). -- ─────────────────────────────────────────────────────────────────────────── DO $$ DECLARE rls_enabled BOOLEAN; BEGIN -- If RLS is enabled on time_tracking_logs, mirror it on the new tables. SELECT relrowsecurity INTO rls_enabled FROM pg_class WHERE relname = 'time_tracking_logs'; IF rls_enabled THEN EXECUTE 'ALTER TABLE time_tracking_timesheets ENABLE ROW LEVEL SECURITY'; EXECUTE 'ALTER TABLE time_tracking_audit_log ENABLE ROW LEVEL SECURITY'; EXECUTE 'ALTER TABLE time_tracking_supervisor_assignments ENABLE ROW LEVEL SECURITY'; END IF; END $$; -- ─────────────────────────────────────────────────────────────────────────── -- 7. Triggers — keep `updated_at` fresh -- ─────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION time_tracking_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS time_tracking_timesheets_set_updated_at ON time_tracking_timesheets; CREATE TRIGGER time_tracking_timesheets_set_updated_at BEFORE UPDATE ON time_tracking_timesheets FOR EACH ROW EXECUTE FUNCTION time_tracking_set_updated_at(); -- ─────────────────────────────────────────────────────────────────────────── -- 8. Helper view — admin "what's pending" dashboard -- ─────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE VIEW time_tracking_approval_queue AS SELECT t.id, t.brand_id, t.field_worker_id, fw.name AS worker_name, t.period_start, t.period_end, t.status, t.total_minutes, t.regular_minutes, t.overtime_minutes, t.entry_count, t.submitted_at, t.submitted_notes, t.reviewed_at, t.locked_at FROM time_tracking_timesheets t JOIN field_workers fw ON fw.id = t.field_worker_id; -- ─────────────────────────────────────────────────────────────────────────── -- 9. SECURITY DEFINER RPC — recompute timesheet totals -- Called by the server after any entry add/edit/delete so the cached -- totals on `time_tracking_timesheets` stay in sync with the underlying -- `time_tracking_logs`. -- ─────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION recompute_timesheet_totals(p_timesheet_id UUID) RETURNS VOID AS $$ DECLARE v_brand_id UUID; v_worker_id UUID; v_period_start DATE; v_period_end DATE; v_total INTEGER; v_regular INTEGER; v_ot INTEGER; v_break INTEGER; v_count INTEGER; v_daily JSONB; v_daily_thresh NUMERIC; v_settings_brand UUID; BEGIN -- Load the timesheet header SELECT brand_id, field_worker_id, period_start, period_end INTO v_brand_id, v_worker_id, v_period_start, v_period_end FROM time_tracking_timesheets WHERE id = p_timesheet_id; IF v_brand_id IS NULL THEN RETURN; END IF; -- Pull daily OT threshold from settings (default 8h) SELECT COALESCE(daily_overtime_threshold, 8) INTO v_daily_thresh FROM time_tracking_settings WHERE brand_id = v_brand_id; v_daily_thresh := COALESCE(v_daily_thresh, 8); -- Aggregate underlying logs SELECT COALESCE(SUM( GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60 - COALESCE(lunch_break_minutes, 0) ) ), 0)::INTEGER, COALESCE(SUM(lunch_break_minutes), 0)::INTEGER, COUNT(*)::INTEGER, jsonb_object_agg( d::text, mins ) FILTER (WHERE d IS NOT NULL) INTO v_total, v_break, v_count, v_daily FROM ( SELECT (clock_in AT TIME ZONE 'America/Denver')::date AS d, SUM( GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(clock_out, NOW()) - clock_in)) / 60 - COALESCE(lunch_break_minutes, 0) ) ) AS mins FROM time_tracking_logs WHERE brand_id = v_brand_id AND field_worker_id = v_worker_id AND clock_in >= v_period_start AND clock_in < (v_period_end + INTERVAL '1 day') GROUP BY 1 ) daily_agg RIGHT JOIN time_tracking_logs l ON l.brand_id = v_brand_id AND l.field_worker_id = v_worker_id AND l.clock_in >= v_period_start AND l.clock_in < (v_period_end + INTERVAL '1 day'); v_daily := COALESCE(v_daily, '{}'::jsonb); -- Daily OT = sum across days of (daily_mins - threshold*60)+ v_ot := 0; IF jsonb_typeof(v_daily) = 'object' THEN SELECT COALESCE(SUM(GREATEST(0, (v.value::numeric - v_daily_thresh * 60))), 0)::INTEGER INTO v_ot FROM jsonb_each(v_daily) v; END IF; v_regular := GREATEST(0, v_total - v_ot); UPDATE time_tracking_timesheets SET total_minutes = v_total, regular_minutes = v_regular, overtime_minutes = v_ot, break_minutes = v_break, entry_count = v_count, daily_totals = v_daily WHERE id = p_timesheet_id; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- ─────────────────────────────────────────────────────────────────────────── -- 10. SECURITY DEFINER RPC — get-or-create a timesheet for a worker × period -- ─────────────────────────────────────────────────────────────────────────── CREATE OR REPLACE FUNCTION get_or_create_timesheet( p_brand_id UUID, p_worker_id UUID, p_period_start DATE, p_period_end DATE ) RETURNS UUID AS $$ DECLARE v_id UUID; BEGIN SELECT id INTO v_id FROM time_tracking_timesheets WHERE field_worker_id = p_worker_id AND period_start = p_period_start AND period_end = p_period_end; IF v_id IS NULL THEN INSERT INTO time_tracking_timesheets (brand_id, field_worker_id, period_start, period_end) VALUES (p_brand_id, p_worker_id, p_period_start, p_period_end) RETURNING id INTO v_id; PERFORM recompute_timesheet_totals(v_id); END IF; RETURN v_id; END; $$ LANGUAGE plpgsql SECURITY DEFINER;