Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s

Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
Tyler
2026-06-17 11:36:00 -06:00
parent 52c8a71cd0
commit 11cd2fd01a
22 changed files with 4944 additions and 1137 deletions
+172
View File
@@ -0,0 +1,172 @@
-- 0090_water_log_completion.sql
--
-- Water Log feature completion. The original `0001_init.sql` shipped the
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
-- surface that this migration adds:
--
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
-- - `water_headgates.status` — open / closed / maintenance
-- - `water_headgates.max_flow_gpm` — optional high-water marker
-- - `water_headgates.notes` — free-form location notes
-- - `water_headgates.last_used_at` — denormalized for display
-- - `water_headgates.high_threshold` — optional alert ceiling
-- - `water_headgates.low_threshold` — optional alert floor
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
--
-- - `water_irrigators.role` — "irrigator" | "water_admin"
-- - `water_irrigators.phone` — optional contact
-- - `water_irrigators.notes` — free-form
--
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
-- - `water_log_entries.total_gallons` — derived when computable
-- - `water_log_entries.photo_url` — link to storage object
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
-- - `water_log_entries.brand_id` index upgrade
--
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
-- - NEW `water_audit_log` (who changed what, when)
--
-- All new tables follow project conventions:
-- - TIMESTAMPTZ for timestamps
-- - UUID PKs via gen_random_uuid()
-- - brand_id scoped, RLS enabled, FORCE'd
-- - CREATE TABLE IF NOT EXISTS for re-runnability
--
-- ============================================================================
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
ALTER TABLE water_headgates
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
DEFAULT encode(gen_random_bytes(12), 'hex'),
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed', 'maintenance')),
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- Backfill any existing rows that pre-date the default expression
UPDATE water_headgates
SET headgate_token = encode(gen_random_bytes(12), 'hex')
WHERE headgate_token IS NULL;
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
ALTER TABLE water_irrigators
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
CHECK (role IN ('irrigator', 'water_admin')),
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT;
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
ALTER TABLE water_log_entries
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
ADD COLUMN IF NOT EXISTS photo_url TEXT,
ADD COLUMN IF NOT EXISTS logged_date DATE,
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
-- Backfill logged_date for any rows that pre-date the column
UPDATE water_log_entries
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
-- Index for fast "today's entries" + "this week's entries" queries
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
ON water_log_entries (brand_id, logged_date DESC);
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
ON water_log_entries (irrigator_id, logged_at DESC);
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS water_admin_settings (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
session_duration_hours INTEGER NOT NULL DEFAULT 4
CHECK (session_duration_hours BETWEEN 1 AND 168),
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
can_export_csv BOOLEAN NOT NULL DEFAULT true,
alert_phone TEXT,
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID REFERENCES admin_users(id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
CREATE TABLE IF NOT EXISTS water_admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
pin_hash_used TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
ON water_admin_sessions (admin_user_id, expires_at DESC);
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
CREATE TABLE IF NOT EXISTS water_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),
actor_label TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
ON water_audit_log (brand_id, created_at DESC);
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
+144 -2
View File
@@ -1,5 +1,15 @@
/**
* Water Log. Source: `db/migrations/0001_init.sql`.
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
*
* Six tables, all brand-scoped with RLS:
* - water_headgates — physical gates a measurement is tied to
* - water_irrigators — PIN-authenticated field workers
* - water_sessions — short-lived PIN sessions for irrigators
* - water_log_entries — the actual reading logs
* - water_alert_log — high/low threshold alert history
* - water_admin_settings — per-brand admin PIN + alert config
* - water_admin_sessions — admin sign-in sessions (separate cookie)
* - water_audit_log — who changed what, when
*/
import {
pgTable,
@@ -8,7 +18,13 @@ import {
numeric,
boolean,
timestamp,
date,
jsonb,
doublePrecision,
index,
uniqueIndex,
integer,
check,
} from "drizzle-orm/pg-core";
import { brands } from "./brands";
import { adminUsers } from "./brands";
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(),
/** Per-headgate opaque token used in the QR code. */
headgateToken: text("headgate_token").notNull().unique(),
/** Open / Closed / Maintenance. */
status: text("status").notNull().default("open"),
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
unit: text("unit").notNull().default("CFS"),
/** Optional max-flow marker in GPM. */
maxFlowGpm: numeric("max_flow_gpm"),
/** High-water alert threshold (units match `unit`). */
highThreshold: numeric("high_threshold"),
/** Low-water alert threshold (units match `unit`). */
lowThreshold: numeric("low_threshold"),
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_headgates_brand_idx").on(t.brandId),
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
}),
);
export const waterIrrigators = pgTable(
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
languagePreference: text("language_preference")
.notNull()
.default("en"),
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
role: text("role").notNull().default("irrigator"),
phone: text("phone"),
notes: text("notes"),
active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
}),
);
export const waterSessions = pgTable(
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
irrigatorId: uuid("irrigator_id")
.notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(),
unit: text("unit").notNull(),
/** "manual" | "meter" | "estimate" | "qr" */
method: text("method").notNull().default("manual"),
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
totalGallons: numeric("total_gallons"),
notes: text("notes"),
submittedVia: text("submitted_via").notNull().default("app"),
photoUrl: text("photo_url"),
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
loggedDate: date("logged_date"),
loggedAt: timestamp("logged_at", { withTimezone: true })
.notNull()
.defaultNow(),
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
(t) => ({
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
brandDateIdx: index("water_log_entries_brand_date_idx").on(
t.brandId,
t.loggedDate,
),
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
t.irrigatorId,
t.loggedAt,
),
}),
);
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
alertType: text("alert_type").notNull(),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
onDelete: "set null",
}),
message: text("message").notNull(),
sentTo: text("sent_to"),
sentAt: timestamp("sent_at", { withTimezone: true }),
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
},
);
export const waterAdminSettings = pgTable("water_admin_settings", {
brandId: uuid("brand_id")
.primaryKey()
.references(() => brands.id, { onDelete: "cascade" }),
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
pinHash: text("pin_hash"),
enabled: boolean("enabled").notNull().default(true),
sessionDurationHours: integer("session_duration_hours")
.notNull()
.default(4),
canEditEntries: boolean("can_edit_entries").notNull().default(true),
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
canExportCsv: boolean("can_export_csv").notNull().default(true),
alertPhone: text("alert_phone"),
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedBy: uuid("updated_by").references(() => adminUsers.id),
});
export const waterAdminSessions = pgTable(
"water_admin_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
adminUserId: uuid("admin_user_id")
.notNull()
.references(() => adminUsers.id, { onDelete: "cascade" }),
pinHashUsed: text("pin_hash_used").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
adminIdx: index("water_admin_sessions_admin_idx").on(
t.adminUserId,
t.expiresAt,
),
}),
);
export const waterAuditLog = pgTable(
"water_audit_log",
{
id: uuid("id").primaryKey().defaultRandom(),
brandId: uuid("brand_id")
.notNull()
.references(() => brands.id, { onDelete: "cascade" }),
actorId: uuid("actor_id").references(() => adminUsers.id),
actorLabel: text("actor_label").notNull(),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id"),
details: jsonb("details"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
t.brandId,
t.createdAt,
),
}),
);
// ── Inferred types (used by every action and client component) ────────────
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;