Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
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:
+144
-2
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* Water Log. Source: `db/migrations/0001_init.sql`.
|
||||
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
|
||||
*
|
||||
* Six tables, all brand-scoped with RLS:
|
||||
* - water_headgates — physical gates a measurement is tied to
|
||||
* - water_irrigators — PIN-authenticated field workers
|
||||
* - water_sessions — short-lived PIN sessions for irrigators
|
||||
* - water_log_entries — the actual reading logs
|
||||
* - water_alert_log — high/low threshold alert history
|
||||
* - water_admin_settings — per-brand admin PIN + alert config
|
||||
* - water_admin_sessions — admin sign-in sessions (separate cookie)
|
||||
* - water_audit_log — who changed what, when
|
||||
*/
|
||||
import {
|
||||
pgTable,
|
||||
@@ -8,7 +18,13 @@ import {
|
||||
numeric,
|
||||
boolean,
|
||||
timestamp,
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
index,
|
||||
uniqueIndex,
|
||||
integer,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
/** Per-headgate opaque token used in the QR code. */
|
||||
headgateToken: text("headgate_token").notNull().unique(),
|
||||
/** Open / Closed / Maintenance. */
|
||||
status: text("status").notNull().default("open"),
|
||||
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
|
||||
unit: text("unit").notNull().default("CFS"),
|
||||
/** Optional max-flow marker in GPM. */
|
||||
maxFlowGpm: numeric("max_flow_gpm"),
|
||||
/** High-water alert threshold (units match `unit`). */
|
||||
highThreshold: numeric("high_threshold"),
|
||||
/** Low-water alert threshold (units match `unit`). */
|
||||
lowThreshold: numeric("low_threshold"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
|
||||
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterIrrigators = pgTable(
|
||||
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
|
||||
languagePreference: text("language_preference")
|
||||
.notNull()
|
||||
.default("en"),
|
||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||
role: text("role").notNull().default("irrigator"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
/** Raw measurement value, in the entry's `unit`. */
|
||||
measurement: numeric("measurement").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
/** "manual" | "meter" | "estimate" | "qr" */
|
||||
method: text("method").notNull().default("manual"),
|
||||
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
|
||||
totalGallons: numeric("total_gallons"),
|
||||
notes: text("notes"),
|
||||
submittedVia: text("submitted_via").notNull().default("app"),
|
||||
photoUrl: text("photo_url"),
|
||||
latitude: doublePrecision("latitude"),
|
||||
longitude: doublePrecision("longitude"),
|
||||
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
|
||||
loggedDate: date("logged_date"),
|
||||
loggedAt: timestamp("logged_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
|
||||
(t) => ({
|
||||
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
||||
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
||||
brandDateIdx: index("water_log_entries_brand_date_idx").on(
|
||||
t.brandId,
|
||||
t.loggedDate,
|
||||
),
|
||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||
t.irrigatorId,
|
||||
t.loggedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
alertType: text("alert_type").notNull(),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
message: text("message").notNull(),
|
||||
sentTo: text("sent_to"),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
export const waterAdminSettings = pgTable("water_admin_settings", {
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
|
||||
pinHash: text("pin_hash"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
sessionDurationHours: integer("session_duration_hours")
|
||||
.notNull()
|
||||
.default(4),
|
||||
canEditEntries: boolean("can_edit_entries").notNull().default(true),
|
||||
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
|
||||
canExportCsv: boolean("can_export_csv").notNull().default(true),
|
||||
alertPhone: text("alert_phone"),
|
||||
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedBy: uuid("updated_by").references(() => adminUsers.id),
|
||||
});
|
||||
|
||||
export const waterAdminSessions = pgTable(
|
||||
"water_admin_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
adminUserId: uuid("admin_user_id")
|
||||
.notNull()
|
||||
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
||||
pinHashUsed: text("pin_hash_used").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
adminIdx: index("water_admin_sessions_admin_idx").on(
|
||||
t.adminUserId,
|
||||
t.expiresAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterAuditLog = pgTable(
|
||||
"water_audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
actorId: uuid("actor_id").references(() => adminUsers.id),
|
||||
actorLabel: text("actor_label").notNull(),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: uuid("entity_id"),
|
||||
details: jsonb("details"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Inferred types (used by every action and client component) ────────────
|
||||
|
||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||
|
||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||
|
||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
|
||||
|
||||
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user