Files
route-commerce/src/lib/water-log-audit.ts
T
Tyler 11cd2fd01a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
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).
2026-06-17 11:36:00 -06:00

83 lines
2.5 KiB
TypeScript

/**
* Water Log — small audit + alert helpers.
*
* Every mutating action in `actions/water-log/*` calls `logAuditEvent()`
* so a regulator or curious admin can answer "who deleted that headgate
* on Tuesday" without trawling server logs.
*/
import "server-only";
import { query } from "@/lib/db";
import { withBrand } from "@/db/client";
export type WaterAuditEvent = {
brandId: string;
actorLabel: string;
actorId?: string | null;
action: string;
entityType: "headgate" | "user" | "entry" | "settings" | "session";
entityId?: string | null;
details?: Record<string, unknown> | null;
};
/**
* Fire-and-forget audit insert. The withBrand wrapper sets the brand
* context so RLS lets the write through. We swallow errors so a broken
* audit log never blocks the user-facing action — but we always log to
* the console as a backstop.
*/
export async function logAuditEvent(ev: WaterAuditEvent): Promise<void> {
try {
await withBrand(ev.brandId, async () => {
await query(
`INSERT INTO water_audit_log
(brand_id, actor_id, actor_label, action, entity_type, entity_id, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
ev.brandId,
ev.actorId ?? null,
ev.actorLabel,
ev.action,
ev.entityType,
ev.entityId ?? null,
ev.details ? JSON.stringify(ev.details) : null,
],
);
});
} catch (err) {
// Audit must not break the user flow. Surface to console.
console.error("[water-log] audit insert failed", err, ev);
}
}
/**
* Log a high/low water alert. Currently this is a passive record — the
* scheduled job that actually sends SMS/WhatsApp can pick rows where
* `sent_at IS NULL`. Phase 2 wires up the sender.
*/
export async function logAlert(args: {
brandId: string;
headgateId: string | null;
alertType: "high" | "low";
reading: number;
threshold: number;
headgateName: string;
}): Promise<void> {
try {
await withBrand(args.brandId, async () => {
await query(
`INSERT INTO water_alert_log
(brand_id, alert_type, headgate_id, message)
VALUES ($1, $2, $3, $4)`,
[
args.brandId,
args.alertType,
args.headgateId,
`${args.alertType.toUpperCase()} alert on ${args.headgateName}: reading ${args.reading} crossed threshold ${args.threshold}`,
],
);
});
} catch (err) {
console.error("[water-log] alert insert failed", err, args);
}
}