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
+36 -42
View File
@@ -1,52 +1,46 @@
/**
* POST /api/water-admin-auth
*
* Body: { brandId: string, pin: string }
* Side effect: sets the `wl_admin_session` cookie on success.
*
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
* itself is created by `verifyWaterAdminPin()` in
* `src/actions/water-log/settings.ts`, which is the single source of
* truth for admin PIN verification.
*/
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { pool } from "@/lib/db";
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
import { captureError } from "@/lib/sentry";
export async function POST(request: Request) {
try {
const { brandId, pin } = await request.json();
const { brandId, pin } = (await request.json()) as {
brandId?: string;
pin?: string;
};
if (!brandId || !pin) {
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
return NextResponse.json(
{ success: false, error: "Missing brandId or pin" },
{ status: 400 },
);
}
// Get admin settings
const settingsRes = await pool.query<{
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
}>(
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
[brandId],
);
const settings = settingsRes.rows[0]?.get_water_admin_settings;
if (!settings?.enabled) {
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
const result = await verifyWaterAdminPin(brandId, pin);
if (!result.success) {
// Don't leak whether the PIN was wrong vs. not configured — both
// are the same to a field attacker probing the endpoint.
const status = result.error === "Invalid PIN" ? 401 : 403;
return NextResponse.json(
{ success: false, error: "Invalid PIN" },
{ status },
);
}
// Verify PIN
const verifyRes = await pool.query<{
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
}>(
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
[brandId, pin],
);
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
if (!verifyData?.success || !verifyData.session_id) {
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
}
// Create session cookie
const sessionId = verifyData.session_id;
const cookieStore = await cookies();
const durationHours = settings.session_duration_hours ?? 4;
cookieStore.set("wl_admin_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: durationHours * 3600,
path: "/",
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
} catch (err) {
captureError(err as Error, { path: "/api/water-admin-auth" });
return NextResponse.json(
{ success: false, error: "Server error" },
{ status: 500 },
);
}
}