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
+246 -169
View File
@@ -1,25 +1,43 @@
"use client";
/**
* /admin/water-log/settings
*
* Site-admin-facing settings page for the Water Log module. Controls:
* - Whether `/water/admin` is enabled
* - 4-digit admin PIN (regenerate on demand)
* - Session duration (hours)
* - Per-coarse-permission flags (edit / delete / export)
* - High/low alert phone + enable flag
*
* Visual language: Field Almanac. Same cream + forest palette as
* the main WaterLogAdminPanel, but trimmed to a single column for
* a focused "form" feel.
*/
import { useState, useEffect, useCallback } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
import { useRouter } from "next/navigation";
import {
getWaterAdminSettings,
saveWaterAdminSettings,
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() {
const router = useRouter();
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [revealedPin, setRevealedPin] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state
const [enabled, setEnabled] = useState(false);
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(false);
const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
if (data) {
setSettings(data);
setEnabled(data.enabled);
setSessionDuration(data.session_duration_hours);
setCanEdit(data.can_edit_entries);
setCanDelete(data.can_delete_entries);
setCanExport(data.can_export_csv);
setAlertPhone(data.alert_phone ?? "");
setAlertsEnabled(data.alerts_enabled ?? false);
setSessionDuration(data.sessionDurationHours);
setCanEdit(data.canEditEntries);
setCanDelete(data.canDeleteEntries);
setCanExport(data.canExportCsv);
setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alertsEnabled);
}
setLoading(false);
}, []);
useEffect(() => {
const init = async () => {
await loadSettings();
};
init();
// Load on mount — setState-in-effect is intentional here
// because we're hydrating from server data.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadSettings();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (enabled && pin && pin !== confirmPin) {
setMessage({ type: "error", text: "PINs do not match" });
return;
}
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
return;
}
setSaving(true);
setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled,
pin: pin || undefined,
session_duration_hours: sessionDuration,
can_edit_entries: canEdit,
can_delete_entries: canDelete,
can_export_csv: canExport,
alert_phone: alertPhone || null,
alerts_enabled: alertsEnabled,
sessionDurationHours: sessionDuration,
canEditEntries: canEdit,
canDeleteEntries: canDelete,
canExportCsv: canExport,
alertPhone: alertPhone || null,
alertsEnabled,
});
if (result.success) {
setMessage({ type: "success", text: "Settings saved" });
setPin("");
setConfirmPin("");
if (result.settings) setSettings(result.settings);
} else {
setMessage({ type: "error", text: result.error ?? "Save failed" });
}
setSaving(false);
}
async function handleRegenerate() {
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
return;
}
setRegenerating(true);
setMessage(null);
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
if (result.success && result.pin) {
setRevealedPin(result.pin);
setMessage({
type: "success",
text: "New PIN generated. Save it now — it will not be shown again.",
});
} else {
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
}
setRegenerating(false);
}
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<span className="text-zinc-500">Loading...</span>
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
<span className="text-[#5a5d5a]">Loading</span>
</div>
);
}
return (
<div className="min-h-screen bg-zinc-950">
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm"> Back</button>
<div>
<h1 className="text-xl font-bold text-zinc-100">Water Log Admin Portal</h1>
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
</div>
</div>
<div className="border-b border-[#d4d9d3] bg-white">
<div className="mx-auto max-w-2xl px-6 py-6">
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 Settings</p>
<h1
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
Water Log · Admin Portal
</h1>
<p className="mt-2 text-sm text-[#5a5d5a]">
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
This is separate from the platform admin login.
</p>
</div>
</div>
<div className="mx-auto max-w-lg px-6 py-6">
<div className="mx-auto max-w-2xl px-6 py-8">
<form onSubmit={handleSave} className="space-y-6">
{message && (
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
<div
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
message.type === "success"
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
}`}
>
{message.text}
</div>
)}
{/* Enable toggle */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
</div>
<button
type="button"
onClick={() => setEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
</div>
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
<ToggleRow
label="Enable"
description="Allow water admins to sign in with a 4-digit PIN."
value={enabled}
onChange={setEnabled}
/>
</Card>
{enabled && (
<>
{/* PIN */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
<input
id="water-new-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
<div>
<label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input
id="water-confirm-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="••••"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
style={{ letterSpacing: "0.4em" }}
/>
</div>
</div>
</div>
{/* Session Duration */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Session Duration</p>
<p className="text-xs text-zinc-500">How long the admin session lasts after login (172 hours).</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={72}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
className="flex-1"
/>
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
</div>
</div>
{/* Permissions */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
<p className="font-semibold text-zinc-100">Permissions</p>
{[
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
].map(({ key, label, desc, value, setter }) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-500">{desc}</p>
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
{revealedPin ? (
<div className="space-y-3">
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
New PIN write it down
</p>
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
<span
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
>
{revealedPin}
</span>
</div>
<button
type="button"
onClick={() => setter((v: boolean) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
onClick={() => setRevealedPin(null)}
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
I&apos;ve saved it dismiss
</button>
</div>
))}
</div>
) : (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-[#5a5d5a]">
{settings?.pin === null
? "Generate a new PIN to give to your water admin."
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
</p>
<button
type="button"
onClick={handleRegenerate}
disabled={regenerating}
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
>
{regenerating ? "Generating…" : "Regenerate PIN"}
</button>
</div>
)}
</Card>
{/* Session Duration */}
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1168 hours)">
<div className="flex items-center gap-4">
<input
type="range"
min={1}
max={168}
value={sessionDuration}
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
className="flex-1 accent-[#1a4d2e]"
aria-label="Session duration in hours"
/>
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
{sessionDuration}h
</span>
</div>
</Card>
{/* Permissions */}
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
<div className="divide-y divide-[#d4d9d3]">
<ToggleRow
label="Edit entries"
description="Allow modifying existing log entries."
value={canEdit}
onChange={setCanEdit}
/>
<ToggleRow
label="Delete entries"
description="Allow removing log entries."
value={canDelete}
onChange={setCanDelete}
/>
<ToggleRow
label="Export CSV"
description="Allow exporting water log data."
value={canExport}
onChange={setCanExport}
/>
</div>
</Card>
{/* High/Low Alerts */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate&apos;s thresholds.</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
</div>
<button
type="button"
onClick={() => setAlertsEnabled((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
<ToggleRow
label="Enable alerts"
description="Threshold breach notifications."
value={alertsEnabled}
onChange={setAlertsEnabled}
/>
{alertsEnabled && (
<div>
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
<div className="mt-4">
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
Alert phone number
</label>
<input
id="water-alert-phone"
type="tel"
value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+1234567890"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
placeholder="+13035551234"
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
autoComplete="tel"
/>
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
<p className="mt-1 text-xs text-[#8a8b88]">
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
</p>
</div>
)}
</div>
{/* QR hint */}
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
<p className="text-xs text-amber-700">
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
</p>
</div>
</Card>
</>
)}
<button
type="submit"
disabled={saving}
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
{saving ? "Saving" : "Save Settings"}
</button>
</form>
</div>
</div>
);
}
}
function Card({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
<header className="mb-4">
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
</header>
{children}
</section>
);
}
function ToggleRow({
label,
description,
value,
onChange,
}: {
label: string;
description?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(!value)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
);
}
+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 },
);
}
}
+61 -35
View File
@@ -1,60 +1,86 @@
/**
* GET /api/water-logs/export
*
* Streams the water log as JSON or CSV. Admin-only — checked via
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
* session, with Tuxedo fallback for backward compat.
*
* GET /api/water-logs/export?format=csv
*/
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getWaterEntries } from "@/actions/water-log/admin";
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export async function GET(request: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_water_log) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json";
const brandId =
adminUser.brand_id ??
process.env.TUXEDO_BRAND_ID ??
TUXEDO_BRAND_ID;
// Use brand_id from session (always Tuxedo for water log) or fallback to env
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
type WaterEntry = {
id: string;
user_id: string | null;
headgate_id: string | null;
measurement: number | null;
unit: string | null;
notes: string | null;
created_at: string;
};
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
[brandId, 10000],
);
const entries = data[0]?.get_water_entries ?? [];
const raw = await getWaterEntries(brandId, 10000);
const rows: WaterLogReportRow[] = raw.map((r) => ({
logged_at: r.logged_at,
headgate_name: r.headgate_name,
user_name: r.user_name,
user_role: "irrigator",
measurement: r.measurement,
unit: r.unit,
notes: r.notes,
submitted_via: r.submitted_via,
}));
if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
const csvRows = [headers.join(",")];
for (const row of entries) {
csvRows.push([
row.id,
row.user_id ?? "",
row.headgate_id ?? "",
row.measurement ?? "",
row.unit ?? "",
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
row.created_at ?? "",
].join(","));
const headers = [
"When",
"Headgate",
"User",
"Measurement",
"Unit",
"Total Gallons",
"Method",
"Notes",
"Via",
"Photo URL",
];
const esc = (s: string | null | undefined) =>
`"${(s ?? "").replace(/"/g, '""')}"`;
const lines: string[] = [headers.join(",")];
for (const e of raw) {
lines.push(
[
esc(e.logged_at),
esc(e.headgate_name),
esc(e.user_name),
String(e.measurement),
esc(e.unit),
e.total_gallons != null ? String(e.total_gallons) : "",
esc(e.method),
esc(e.notes),
esc(e.submitted_via),
esc(e.photo_url),
].join(","),
);
}
return new NextResponse(csvRows.join("\n"), {
return new NextResponse(lines.join("\n"), {
headers: {
"Content-Type": "text/csv",
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
}
return NextResponse.json(entries);
return NextResponse.json({ rows });
}