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
+67 -32
View File
@@ -10,6 +10,7 @@ import {
setWaterLang,
} from "@/actions/water-log/field";
import { useSearchParams } from "next/navigation";
import { WaterGauge } from "@/components/water/icons";
type Headgate = {
id: string;
@@ -345,10 +346,13 @@ function WaterFieldInner() {
// Language selection screen
if (step === "loading") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-500 text-base">Loading...</p>
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
);
@@ -357,20 +361,31 @@ function WaterFieldInner() {
// Language selection screen
if (step === "lang") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p>
<div className="flex flex-col gap-4">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button
onClick={() => handleLangSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button
onClick={() => handleLangSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
@@ -383,26 +398,31 @@ function WaterFieldInner() {
// Role selection screen
if (step === "role") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button
onClick={() => setStep("lang")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-4">
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-stone-700 hover:bg-stone-800 min-h-[64px]"
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
@@ -415,16 +435,21 @@ function WaterFieldInner() {
// PIN entry screen
if (step === "pin") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button
onClick={() => setStep("role")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={handlePinSubmit}>
<input
@@ -435,17 +460,17 @@ function WaterFieldInner() {
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4"
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
autoFocus
/>
{error && (
<p className="text-center text-red-600 text-sm mb-4">{error}</p>
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 min-h-[64px]"
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
@@ -459,17 +484,27 @@ function WaterFieldInner() {
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
return (
<div className="min-h-screen bg-stone-50">
<div className="min-h-screen bg-[#fdfaf2]">
{/* Header */}
<div className="bg-stone-900 px-4 py-4">
<div className="bg-[#1a4d2e] px-4 py-4">
<div className="mx-auto max-w-lg flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white">{t.title}</h1>
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p>
<div className="flex items-center gap-3">
<WaterGauge size={32} level={null} status="open" />
<div>
<h1
className="text-xl font-bold text-white"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-xs text-[#bbf7d0] font-mono uppercase tracking-wider">
{t.loggedInAs}: {irrigatorName}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]"
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
>
{t.logout}
</button>
@@ -477,7 +512,7 @@ function WaterFieldInner() {
</div>
{/* Step progress indicator */}
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2">
<div className="bg-[#fafaf7] border-b border-[#d4d9d3] px-4 py-2">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-1.5">
{["lang", "role", "pin", "form"].map((s, i) => {
@@ -486,8 +521,8 @@ function WaterFieldInner() {
const isPast = stepIndex > i;
return (
<div key={s} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />}
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-[#1a4d2e]" : isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
</div>
);
})}