11cd2fd01a
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).
83 lines
2.5 KiB
TypeScript
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);
|
|
}
|
|
}
|