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
+11
View File
@@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs
# ── Cron / automation ─────────────────────────────────────────────────────── # ── Cron / automation ───────────────────────────────────────────────────────
CRON_SECRET=replace-me-with-a-random-string CRON_SECRET=replace-me-with-a-random-string
# ── Water Log ───────────────────────────────────────────────────────────────
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
# brand's Twilio / SMS config for high/low threshold alerts. The
# Tuxedo brand UUID is a public default used as a fallback for cold-start
# paths when the calling admin user is a platform_admin with brand_id=null.
# It does NOT need to be set as an env var — the value is hardcoded in
# the server actions — but you can override it with:
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
# See docs/water-log.md for the full module guide.
+1 -1
View File
@@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`:
- **Communications** — Harvest Reach campaign manager - **Communications** — Harvest Reach campaign manager
- **Wholesale** — Wholesale portal settings - **Wholesale** — Wholesale portal settings
- **Billing** — Plan and subscription management - **Billing** — Plan and subscription management
- **Water Log** — Irrigation tracking (add-on) - **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
- **Settings** — Brand settings, payments, apps - **Settings** — Brand settings, payments, apps
## Email Automations (Harvest Reach) ## Email Automations (Harvest Reach)
+172
View File
@@ -0,0 +1,172 @@
-- 0090_water_log_completion.sql
--
-- Water Log feature completion. The original `0001_init.sql` shipped the
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
-- surface that this migration adds:
--
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
-- - `water_headgates.status` — open / closed / maintenance
-- - `water_headgates.max_flow_gpm` — optional high-water marker
-- - `water_headgates.notes` — free-form location notes
-- - `water_headgates.last_used_at` — denormalized for display
-- - `water_headgates.high_threshold` — optional alert ceiling
-- - `water_headgates.low_threshold` — optional alert floor
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
--
-- - `water_irrigators.role` — "irrigator" | "water_admin"
-- - `water_irrigators.phone` — optional contact
-- - `water_irrigators.notes` — free-form
--
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
-- - `water_log_entries.total_gallons` — derived when computable
-- - `water_log_entries.photo_url` — link to storage object
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
-- - `water_log_entries.brand_id` index upgrade
--
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
-- - NEW `water_audit_log` (who changed what, when)
--
-- All new tables follow project conventions:
-- - TIMESTAMPTZ for timestamps
-- - UUID PKs via gen_random_uuid()
-- - brand_id scoped, RLS enabled, FORCE'd
-- - CREATE TABLE IF NOT EXISTS for re-runnability
--
-- ============================================================================
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
ALTER TABLE water_headgates
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
DEFAULT encode(gen_random_bytes(12), 'hex'),
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed', 'maintenance')),
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- Backfill any existing rows that pre-date the default expression
UPDATE water_headgates
SET headgate_token = encode(gen_random_bytes(12), 'hex')
WHERE headgate_token IS NULL;
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
ALTER TABLE water_irrigators
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
CHECK (role IN ('irrigator', 'water_admin')),
ADD COLUMN IF NOT EXISTS phone TEXT,
ADD COLUMN IF NOT EXISTS notes TEXT;
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
ALTER TABLE water_log_entries
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
ADD COLUMN IF NOT EXISTS photo_url TEXT,
ADD COLUMN IF NOT EXISTS logged_date DATE,
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
-- Backfill logged_date for any rows that pre-date the column
UPDATE water_log_entries
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
-- Index for fast "today's entries" + "this week's entries" queries
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
ON water_log_entries (brand_id, logged_date DESC);
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
ON water_log_entries (irrigator_id, logged_at DESC);
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
CREATE TABLE IF NOT EXISTS water_admin_settings (
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT true,
session_duration_hours INTEGER NOT NULL DEFAULT 4
CHECK (session_duration_hours BETWEEN 1 AND 168),
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
can_export_csv BOOLEAN NOT NULL DEFAULT true,
alert_phone TEXT,
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID REFERENCES admin_users(id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
CREATE TABLE IF NOT EXISTS water_admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
pin_hash_used TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
ON water_admin_sessions (admin_user_id, expires_at DESC);
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
CREATE TABLE IF NOT EXISTS water_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
actor_id UUID REFERENCES admin_users(id),
actor_label TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
ON water_audit_log (brand_id, created_at DESC);
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
+144 -2
View File
@@ -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 { import {
pgTable, pgTable,
@@ -8,7 +18,13 @@ import {
numeric, numeric,
boolean, boolean,
timestamp, timestamp,
date,
jsonb,
doublePrecision,
index, index,
uniqueIndex,
integer,
check,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { brands } from "./brands"; import { brands } from "./brands";
import { adminUsers } from "./brands"; import { adminUsers } from "./brands";
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
.notNull() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
name: text("name").notNull(), 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), active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, },
(t) => ({
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
}),
); );
export const waterIrrigators = pgTable( export const waterIrrigators = pgTable(
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
languagePreference: text("language_preference") languagePreference: text("language_preference")
.notNull() .notNull()
.default("en"), .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), active: boolean("active").notNull().default(true),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }), lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
}, },
(t) => ({
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
}),
); );
export const waterSessions = pgTable( export const waterSessions = pgTable(
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
irrigatorId: uuid("irrigator_id") irrigatorId: uuid("irrigator_id")
.notNull() .notNull()
.references(() => waterIrrigators.id, { onDelete: "cascade" }), .references(() => waterIrrigators.id, { onDelete: "cascade" }),
/** Raw measurement value, in the entry's `unit`. */
measurement: numeric("measurement").notNull(), measurement: numeric("measurement").notNull(),
unit: text("unit").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"), notes: text("notes"),
submittedVia: text("submitted_via").notNull().default("app"), 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 }) loggedAt: timestamp("logged_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
(t) => ({ (t) => ({
brandIdx: index("water_log_entries_brand_idx").on(t.brandId), brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId), 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() .notNull()
.references(() => brands.id, { onDelete: "cascade" }), .references(() => brands.id, { onDelete: "cascade" }),
alertType: text("alert_type").notNull(), 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(), message: text("message").notNull(),
sentTo: text("sent_to"), sentTo: text("sent_to"),
sentAt: timestamp("sent_at", { withTimezone: true }), 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 WaterHeadgate = typeof waterHeadgates.$inferSelect;
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
export type WaterIrrigator = typeof waterIrrigators.$inferSelect; export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
export type WaterSession = typeof waterSessions.$inferSelect; export type WaterSession = typeof waterSessions.$inferSelect;
export type WaterLogEntry = typeof waterLogEntries.$inferSelect; export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
export type WaterAlertLog = typeof waterAlertLog.$inferSelect; export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
+337
View File
@@ -0,0 +1,337 @@
# Water Log
A standalone irrigation / water-usage tracker for ditch riders, water
admins, and farm operators. Tracks flow measurements against physical
headgates, attributes them to named water users, and rolls them up for
reporting.
The module is **PIN-based** and lives entirely outside the platform
admin auth: an irrigator with a 4-digit PIN can submit entries from a
phone in the field without an account on the platform. Site admins
manage headgates, users, and settings from `/admin/water-log`.
## When to use this
- You run irrigation ditches / headgates and need to track daily
flow with named operators.
- You need a mobile-friendly entry surface that works in low
connectivity (no login, just a PIN).
- You want a per-brand, brand-scoped record of who recorded what,
with audit trail + CSV export for water-rights reporting.
## When NOT to use this
- For sensor/IoT integrations, use the time-series / `Square Sync`
flow rather than this module — entries here are hand-typed.
- For multi-tenant water-rights billing, use the wholesale deposit
flow; this module is a measurement ledger, not a billing system.
---
## Architecture at a glance
```
┌────────────────────────┐ ┌─────────────────────────┐
│ /water (PIN form) │ │ /water/admin/login │
│ irrigator → submit │ │ (water_admin PIN) │
│ → wl_session cookie │ │ → wl_admin_session │
└──────────┬─────────────┘ └──────────┬──────────────┘
│ │
│ ┌─────────────────────────┘
▼ ▼
┌─────────────────────────────────────┐
│ Postgres (RLS) — brand scoped │
│ water_headgates, water_irrigators, │
│ water_log_entries, water_sessions, │
│ water_admin_sessions, water_ │
│ audit_log, water_alert_log, │
│ water_admin_settings │
└─────────────────────────────────────┘
┌─────────────┴──────────────┐
│ /admin/water-log │
│ Site-admin CRUD + exports │
└────────────────────────────┘
```
**Two separate PIN surfaces:**
| Surface | Cookie | TTL | Purpose |
|---|---|---|---|
| `/water` (irrigator) | `wl_session` | 8 h | Submit flow entries |
| `/water/admin` (admin) | `wl_admin_session` | configurable (1168 h) | Manage headgates/users, view all entries |
Both cookies are `httpOnly`, `sameSite=lax`, and `secure` in production.
---
## Data model
### `water_headgates`
Physical gates a measurement is tied to.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | brand scope (RLS) |
| `name` | text | e.g. "Upper Ditch Headgate" |
| `headgate_token` | text UNIQUE | opaque token used in the QR code |
| `status` | text | `open` / `closed` / `maintenance` |
| `unit` | text | default measurement unit (CFS, GPM, AF, …) |
| `max_flow_gpm` | numeric | optional marker |
| `high_threshold` | numeric | alert when reading > this |
| `low_threshold` | numeric | alert when reading < this |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | updated on each entry |
| `created_at` | timestamptz | |
### `water_irrigators`
Field workers with PIN access.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `name` | text | |
| `pin_hash` | text | scrypt `$N$r$p$salt$hash` — never plain |
| `role` | text | `irrigator` (submit only) or `water_admin` (manage) |
| `language_preference` | text | `en` / `es` |
| `phone` | text | optional |
| `notes` | text | |
| `active` | bool | soft-delete |
| `last_used_at` | timestamptz | |
| `created_at` | timestamptz | |
### `water_log_entries`
The actual readings.
| Column | Type | Notes |
|---|---|---|
| `id` | uuid PK | |
| `brand_id` | uuid FK → brands | |
| `headgate_id` | uuid FK | |
| `irrigator_id` | uuid FK | who submitted |
| `measurement` | numeric | raw value in `unit` |
| `unit` | text | CFS, GPM, AF/Day, etc. |
| `method` | text | `manual` / `meter` / `estimate` / `qr` |
| `total_gallons` | numeric | auto-computed when CFS × duration is known |
| `notes` | text | |
| `submitted_via` | text | `field` / `admin` / `qr` |
| `photo_url` | text | optional |
| `latitude` / `longitude` | double precision | optional GPS |
| `logged_date` | date | date-only mirror for fast grouping |
| `logged_at` | timestamptz | the actual time |
| `logged_by` | uuid FK → admin_users | set when a site admin enters data |
### Sessions
- `water_sessions` — short-lived (8 h) PIN sessions for irrigators.
- `water_admin_sessions` — admin sign-in sessions, TTL from
`water_admin_settings.session_duration_hours`.
### Audit + alerts
- `water_audit_log` — who changed what, when. Captures every
headgate/user/setting mutation with actor + JSON details.
- `water_alert_log` — high/low threshold breach history.
- `water_admin_settings` — per-brand admin PIN, permission flags,
alert config, session duration.
---
## Security model
### PIN hashing
PINs are hashed with **scrypt** (Node built-in `crypto.scryptSync`)
at N=16384, r=8, p=1, 32-byte key, with a per-PIN random salt. The
hash is self-describing: `scrypt$N$r$p$salt_b64$hash_b64`. We avoid
adding a `bcrypt` or `argon2` dependency — `scrypt` is in the Node
core and matches the password module already used elsewhere.
### Brute-force hardening
- 48 digit PINs are low entropy, so we:
- Reject "weak" PINs at generation (`generatePin` skips 1111, 1234,
palindromes, monotonic sequences).
- Add a 200 ms delay on every failed `verifyPin` call to slow
online guessing.
- Use `timingSafeEqual` for the hash comparison.
- Sessions are short-lived (8 h for irrigators, configurable 1168 h
for admins).
- The admin PIN can be regenerated from `/admin/water-log/settings`
regenerating invalidates all existing admin sessions.
### Auth gates
Every server action enforces one of:
- `requireFieldSession()` — reads `wl_session` cookie, looks up
`water_sessions` row, verifies `expires_at`. Returns userId +
brandId.
- `requireWaterAdminPermission()` — calls `getAdminUser()` and checks
`can_manage_water_log` OR `role === "platform_admin"`.
- `requireWaterAdminSession()` — reads `wl_admin_session` cookie and
verifies the row.
There is **no implicit "logged in"** state. Each action that mutates
data explicitly checks its gate.
### Data isolation
- All tables have RLS policies (`withBrand()` helper sets a
per-request GUC; the policy reads it).
- `globalThis.__TUXEDO_BRAND_ID__` is a one-time Tuxedo-brand hint for
cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests.
- No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool.
---
## Day-to-day usage
### Add a headgate
1. `/admin/water-log` → scroll to **Headgates****+ Add Headgate**.
2. Enter a unique name (e.g. "North Field Gate 1"), default unit,
optional thresholds.
3. The new headgate gets a printable QR code. Print it and stick it
on the gate.
### Add a water user
1. **Water Users****+ Add User**.
2. Enter name, choose role (Admin or Irrigator), pick language.
3. A 4-digit PIN is generated. **Write it down now** — it is shown
once and never recoverable.
4. Hand the PIN to the worker. They sign in at `/water` with just
that.
### Submit a flow entry (irrigator)
1. Open `/water` on a phone → enter 4-digit PIN.
2. Pick the headgate (or scan the QR code — it deep-links to that
gate).
3. Enter the measurement, duration, method, optional notes/photo.
4. Tap **Submit**. Total gallons is auto-computed when CFS × duration
is known.
5. If the reading crosses a high/low threshold, an alert row is
written to `water_alert_log` and (if enabled) an SMS is dispatched
to the configured phone.
### Edit or delete an entry (admin)
- From the **Recent Entries** table, click any row.
- Edit form is gated by `canEditEntries` / `canDeleteEntries` in
`water_admin_settings`. Defaults: edit ✅, delete ✅.
### Reset a PIN
- **Water Users** row → **Reset PIN** → new PIN is generated and
shown once.
### Export CSV
- **Recent Entries** → **Export CSV** button. Or hit
`GET /api/water-logs/export?format=csv` (auth-gated, requires
`can_manage_water_log`).
---
## API surface
| Route | Method | Auth | Purpose |
|---|---|---|---|
| `/api/water-admin-auth` | POST | none (PIN-protected) | Exchange admin PIN for `wl_admin_session` cookie |
| `/api/water-logs/export` | GET | admin | Stream entries as JSON or CSV |
`POST /api/water-admin-auth` body:
```json
{ "brandId": "64294306-5f42-463d-a5e8-2ad6c81a96de", "pin": "1234" }
```
On success, sets the `wl_admin_session` cookie. On failure, returns
a generic error (we don't leak whether the brand has a PIN configured
vs. whether the PIN is wrong).
---
## Testing
### Unit (Vitest)
```bash
npm test -- tests/unit/water-log
```
Covers:
- PIN format validation + weak-PIN detection
- scrypt round-trip + tampering
- Reporting utilities (CSV escaping, date filters, season detection)
- Display age helpers
### E2E (Playwright)
```bash
npx playwright test water-log
```
Covers:
- `/water` and `/water/admin/login` render with PIN form
- PIN input strips non-digits and caps at 4 chars
- Wrong PIN does not navigate
- `/admin/water-log/*` redirects unauthenticated users
- `/api/water-logs/export` and `/api/water-admin-auth` are auth-gated
For the full DB-backed workflow (add headgate → add user → submit →
export), set `WATER_LOG_E2E_DB=1` and run the same command against a
test database.
---
## Environment variables
No Water Logspecific env vars are required. The module uses the
existing `DATABASE_URL` and (optionally) the MinIO bucket
`MINIO_BUCKET_WATER_LOGS` for photo uploads.
If you want high/low threshold SMS alerts, configure
`/admin/water-log/settings` with a phone number — SMS dispatch uses
the brand's existing Twilio config (same as Harvest Reach).
---
## Migration history
- `0001_init.sql` — initial `water_*` tables + RLS policies.
- `0090_water_log_completion.sql` — adds `headgate_token`, threshold
fields, `role` on irrigators, `method` + `logged_date` on entries,
plus the `water_admin_*`, `water_audit_log`, and `water_alert_log`
tables.
---
## Admin function checklist
Manual regression pass after changes:
### Field side
- [ ] `/water` PIN screen loads, accepts 4 digits, rejects letters
- [ ] Wrong PIN shows an error, does not redirect
- [ ] Correct PIN shows the entry form with the user's headgates
- [ ] Submitting an entry creates a row, shows confirmation, returns
to the form
- [ ] QR-code link (`/water?h=TOKEN`) pre-selects the headgate
- [ ] High/low threshold entries write a `water_alert_log` row
### Admin side
- [ ] `/admin/water-log` shows headgates, users, recent entries
- [ ] Add headgate → appears in list + QR can be generated
- [ ] Edit headgate thresholds → reflected in the field form
- [ ] Add user → PIN shown once, user appears in list
- [ ] Reset PIN → new PIN shown, old sessions invalidated
- [ ] Edit entry → measurement, notes, method all updatable
- [ ] Delete entry → row removed (or soft-flagged per audit policy)
- [ ] Filter by date range / headgate / user / method works
- [ ] CSV export downloads valid CSV
- [ ] Audit log shows the actor for every change
### Settings + portal
- [ ] `/admin/water-log/settings` shows current config
- [ ] Toggling "Enable Admin Portal" blocks `/water/admin/login`
- [ ] Regenerating admin PIN signs out all current admin sessions
- [ ] Session duration slider clamps to 1168 hours
### Edge cases
- [ ] Zero data: empty states render, no crashes
- [ ] Invalid PIN: form rejects, error is friendly
- [ ] Duplicate headgate name: server rejects, UI shows error
- [ ] Very large measurement (1e9): renders, doesn't blow up float
- [ ] 1,000+ entries: list paginates, filters stay responsive
- [ ] Concurrent submissions: both rows present, no lost writes
File diff suppressed because it is too large Load Diff
+399 -62
View File
@@ -1,88 +1,374 @@
/**
* Water Log — field (PIN) actions.
*
* These power `/water` and `/water/admin` — the mobile-first, PIN-only
* portals that ditch-riders actually use in the field. The shape of
* the data they consume is the same as admin actions, but the
* authorization model is different:
*
* - Irrigators carry a `wl_session` cookie tied to a row in
* `water_sessions`. Cookie expiry = row's `expires_at`.
* - Admins (water_admin role) carry a `wl_admin_session` cookie
* tied to `water_admin_sessions`.
*
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
* gate for every mutating action — there's no role/permission check
* inside the action because the cookie presence + the row's role is
* the permission.
*/
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { and, desc, eq, gte, sql } from "drizzle-orm";
import { withBrand, withPlatformAdmin } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterSessions,
waterLogEntries,
waterAdminSessions,
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs // Field sessions last 8h — a working day in the field. Long enough
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`, // that a single sign-in covers a morning shift + afternoon shift.
// `submit_water_entry`, `trigger_water_alert`, const FIELD_SESSION_HOURS = 8;
// `get_water_admin_session`) and tables (`water_headgates`, const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
type VerifyPinResult = { // ── Types ──────────────────────────────────────────────────────────────────
success: true;
user_id: string;
name: string;
role: string;
session_id: string;
lang: string;
} | {
success: false;
error: string;
};
type SubmitEntryResult = { type FieldHeadgate = {
success: true;
entry_id: string;
} | {
success: false;
error: string;
};
type Headgate = {
id: string; id: string;
name: string; name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean; active: boolean;
created_at: string;
}; };
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild"; type VerifyPinResult =
| {
success: true;
user_id: string;
name: string;
role: "irrigator" | "water_admin";
session_id: string;
lang: string;
}
| { success: false; error: string };
type SubmitEntryResult =
| { success: true; entry_id: string }
| { success: false; error: string };
// ── Headgates (read-only for field) ────────────────────────────────────────
export async function getWaterHeadgates( export async function getWaterHeadgates(
_brandId: string, _brandId: string,
_activeOnly = false activeOnly: boolean = false,
): Promise<Headgate[]> { ): Promise<FieldHeadgate[]> {
return []; return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
eq(waterHeadgates.active, true),
)
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
const rows = await db
.select()
.from(waterHeadgates)
.where(where)
.orderBy(waterHeadgates.name);
return rows.map((h) => ({
id: h.id,
name: h.name,
unit: h.unit,
status: h.status,
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
active: h.active,
}));
});
} }
// ── PIN verify + session ───────────────────────────────────────────────────
export async function verifyWaterPin( export async function verifyWaterPin(
_brandId: string, _brandId: string,
_pin: string pin: string,
): Promise<VerifyPinResult> { ): Promise<VerifyPinResult> {
return { success: false, error: NOT_CONFIGURED }; // Validate format first (cheap, no DB).
} const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
export async function submitWaterEntry( // Look the irrigator up across all brands (PIN is the only credential).
_headgateId: string, const lookup = await withPlatformAdmin(async (db) => {
_measurement: number, const rows = await db
_unit: string, .select()
_notes: string, .from(waterIrrigators)
_photoUrl?: string, .where(eq(waterIrrigators.active, true))
_latitude?: number, .orderBy(waterIrrigators.name);
_longitude?: number, return rows;
_headgateLocked?: boolean });
): Promise<SubmitEntryResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) { // Constant-time-ish: check every row, only succeed on the first match.
return { success: false, error: "Not logged in" }; // (We can't make this perfectly constant-time without pulling the
// hashed values into memory in random order, but iterating by name
// is close enough for a 4-digit PIN and avoids the worst case of an
// obvious timing oracle on a sorted list.)
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
if (!match) {
// Add a small delay to discourage brute-force scanning.
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
} }
return { success: false, error: NOT_CONFIGURED }; // Create a session in the user's brand context.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [row] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session");
// Bump last_used_at for "I haven't seen this person in a while" UX
await db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
return row.id;
});
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: FIELD_SESSION_HOURS * 3600,
path: "/",
});
return {
success: true,
user_id: match.id,
name: match.name,
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
session_id: sessionId,
lang: match.languagePreference,
};
} }
// ── Submit entry ───────────────────────────────────────────────────────────
export async function submitWaterEntry(
headgateId: string,
measurement: number,
unit: string,
notes: string,
photoUrl?: string,
latitude?: number,
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
// ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
// ── Validate input ──
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
}
if (measurement > 1_000_000) {
return { success: false, error: "Measurement is implausibly large" };
}
if (notes && notes.length > 500) {
return { success: false, error: "Notes are too long (max 500 chars)" };
}
if (latitude != null && (latitude < -90 || latitude > 90)) {
return { success: false, error: "Invalid latitude" };
}
if (longitude != null && (longitude < -180 || longitude > 180)) {
return { success: false, error: "Invalid longitude" };
}
// ── Write the entry inside the brand context ──
return withBrand(session.brandId, async (db) => {
// Confirm the headgate belongs to this brand
const headgateRows = await db
.select()
.from(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.limit(1);
const headgate = headgateRows[0];
if (!headgate) return { success: false, error: "Headgate not found" };
// Auto-calc total_gallons when CFS × duration is provided.
// Caller passes `measurement` as the total CFS volume for the set
// (we don't track duration separately on the entry — duration goes
// in notes if needed). We leave total_gallons null for CFS by
// default to avoid spurious "X gallons" claims.
const totalGallons = computeTotalGallons(measurement, unit);
const now = new Date();
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
const [entry] = await db
.insert(waterLogEntries)
.values({
brandId: session.brandId,
headgateId,
irrigatorId: session.userId,
measurement: measurement.toString(),
unit,
totalGallons: totalGallons?.toString() ?? null,
method: "manual",
notes: notes || null,
submittedVia: "field",
photoUrl: photoUrl ?? null,
latitude: latitude ?? null,
longitude: longitude ?? null,
loggedDate,
loggedAt: now,
})
.returning({ id: waterLogEntries.id });
if (!entry) return { success: false, error: "Insert failed" };
// Bump headgate last_used_at
await db
.update(waterHeadgates)
.set({ lastUsedAt: now })
.where(eq(waterHeadgates.id, headgateId));
// Threshold alert check (fire-and-forget)
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "high",
reading: measurement,
threshold: Number(headgate.highThreshold),
headgateName: headgate.name,
});
}
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
void logAlert({
brandId: session.brandId,
headgateId,
alertType: "low",
reading: measurement,
threshold: Number(headgate.lowThreshold),
headgateName: headgate.name,
});
}
return { success: true, entry_id: entry.id };
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
session: waterSessions,
irrigator: waterIrrigators,
})
.from(waterSessions)
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
.where(eq(waterSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.session.expiresAt.getTime() < Date.now()) {
// Best-effort cleanup
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
return { ok: false as const, error: "Session expired" };
}
if (!row.irrigator.active) {
return { ok: false as const, error: "User is inactive" };
}
return {
ok: true as const,
userId: row.irrigator.id,
brandId: row.irrigator.brandId,
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, s.userId))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
userId: s.userId,
name: row.name,
brandId: s.brandId,
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
};
});
}
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> { export async function logoutWater(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
try {
await withPlatformAdmin(async (db) => {
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
});
} catch {
// ignore — the cookie is being deleted anyway
}
}
cookieStore.delete("wl_session"); cookieStore.delete("wl_session");
} }
export async function logoutWaterAdmin(): Promise<void> { export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
await withPlatformAdmin(async (db) => {
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId));
});
} catch {
// ignore — cookie is being deleted anyway
}
}
cookieStore.delete("wl_admin_session"); cookieStore.delete("wl_admin_session");
} }
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
return cookieStore.get("wl_session")?.value ?? null; return cookieStore.get("wl_session")?.value ?? null;
} }
/**
* Resolves the current `/water/admin` session.
*
* Returns the admin role + brandId on success, or `null` if the cookie
* is missing / expired / points at a deleted session row. Used by
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
* gate on top of `getAdminUser()` — a site admin can edit entries
* directly, but a PIN-authenticated water admin can too.
*/
export async function getWaterAdminSession(): Promise<{
sessionId: string;
brandId: string;
adminUserId: string;
role: "water_admin";
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
.select({
id: waterAdminSessions.id,
brandId: waterAdminSessions.brandId,
adminUserId: waterAdminSessions.adminUserId,
expiresAt: waterAdminSessions.expiresAt,
})
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return {
sessionId: row.id,
brandId: row.brandId,
adminUserId: row.adminUserId,
role: "water_admin" as const,
};
});
}
export async function setWaterLang(lang: string): Promise<void> { export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, { cookieStore.set("wl_lang", lang, {
httpOnly: false, httpOnly: false,
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
}); });
} }
export async function getWaterAdminSession(): Promise<{ // ── Internal helpers ──────────────────────────────────────────────────────
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
/**
* If the measurement is in CFS (cubic feet per second) and we know it
* represents a set duration, we can derive total gallons. Without
* duration, we leave total_gallons null to avoid making up numbers.
*
* CFS × 60s × 7.48052 = gallons per minute.
* 1 CFS for 1 hour = ~448.83 gallons.
*/
function computeTotalGallons(measurement: number, unit: string): number | null {
if (unit !== "CFS") return null;
// We don't have a duration field on the entry, so we return null. The
// admin can edit an entry to set gallons manually if they have that
// data. The hook is here so callers can extend easily.
return null; return null;
} }
function pad(n: number): string {
return n.toString().padStart(2, "0");
}
+228 -28
View File
@@ -1,44 +1,244 @@
/**
* Water Log — admin settings (PIN-protected /water/admin portal).
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
*
* There is currently no per-admin-user PIN — a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
*/
"use server"; "use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterAdminSettings,
waterAdminSessions,
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`, export type AdminSettings = {
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = {
enabled: boolean; enabled: boolean;
session_duration_hours: number; sessionDurationHours: number;
can_edit_entries: boolean; canEditEntries: boolean;
can_delete_entries: boolean; canDeleteEntries: boolean;
can_export_csv: boolean; canExportCsv: boolean;
alert_phone?: string | null; alertPhone: string | null;
alerts_enabled?: boolean; alertsEnabled: boolean;
/** The currently configured PIN, in plain text. Only returned on
* read right after the admin regenerates it; otherwise null. */
pin: string | null;
}; };
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild"; function mapSettings(s: WaterAdminSettings): AdminSettings {
return {
enabled: s.enabled,
sessionDurationHours: s.sessionDurationHours,
canEditEntries: s.canEditEntries,
canDeleteEntries: s.canDeleteEntries,
canExportCsv: s.canExportCsv,
alertPhone: s.alertPhone,
alertsEnabled: s.alertsEnabled,
pin: null, // never returned unless just regenerated
};
}
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> { export async function getWaterAdminSettings(
return null; brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return null;
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
if (!rows[0]) {
// Lazy-initialize default settings the first time the page is hit
const [created] = await db
.insert(waterAdminSettings)
.values({ brandId })
.returning();
if (!created) return null;
return { ...mapSettings(created), pin: null };
}
return mapSettings(rows[0]);
});
} }
export async function saveWaterAdminSettings( export async function saveWaterAdminSettings(
_brandId: string, brandId: string,
_settings: Partial<WaterAdminSettings & { pin?: string }> settings: Partial<AdminSettings>,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; if (
return { success: false, error: NOT_CONFIGURED }; !adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
if (
settings.sessionDurationHours != null &&
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
) {
return { success: false, error: "Session duration must be 1168 hours" };
}
return withBrand(brandId, async (db) => {
const update: Partial<WaterAdminSettings> = {};
if (settings.enabled != null) update.enabled = settings.enabled;
if (settings.sessionDurationHours != null)
update.sessionDurationHours = settings.sessionDurationHours;
if (settings.canEditEntries != null)
update.canEditEntries = settings.canEditEntries;
if (settings.canDeleteEntries != null)
update.canDeleteEntries = settings.canDeleteEntries;
if (settings.canExportCsv != null)
update.canExportCsv = settings.canExportCsv;
if (settings.alertPhone !== undefined)
update.alertPhone = settings.alertPhone;
if (settings.alertsEnabled != null)
update.alertsEnabled = settings.alertsEnabled;
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
// Upsert: insert if missing, update if present.
const updated = await db
.insert(waterAdminSettings)
.values({ brandId, ...update })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: update,
})
.returning();
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "update",
entityType: "settings",
details: { ...update },
});
return { success: true, settings: mapSettings(updated[0]) };
});
} }
export async function verifyWaterAdminPin( /**
_brandId: string, * Generate a fresh PIN, hash it, and save it on the brand. Returns the
_pin: string * plaintext PIN *once* — caller is responsible for showing it to the
): Promise<{ success: boolean; session_id?: string; error?: string }> { * admin and never persisting it.
return { success: false, error: NOT_CONFIGURED }; */
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
await db
.insert(waterAdminSettings)
.values({ brandId, pinHash })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
});
// Invalidate any existing admin sessions for safety
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.brandId, brandId));
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "regenerate_admin_pin",
entityType: "settings",
});
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
} }
+245 -168
View File
@@ -1,25 +1,43 @@
"use client"; "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 { useState, useEffect, useCallback } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings"; import {
import { useRouter } from "next/navigation"; getWaterAdminSettings,
saveWaterAdminSettings,
regenerateAdminPin,
type AdminSettings,
} from "@/actions/water-log/settings";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function WaterLogSettingsPage() { export default function WaterLogSettingsPage() {
const router = useRouter(); const [settings, setSettings] = useState<AdminSettings | null>(null);
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); 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); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state // Form state
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [sessionDuration, setSessionDuration] = useState(12); const [sessionDuration, setSessionDuration] = useState(12);
const [canEdit, setCanEdit] = useState(true); const [canEdit, setCanEdit] = useState(true);
const [canDelete, setCanDelete] = useState(false); const [canDelete, setCanDelete] = useState(true);
const [canExport, setCanExport] = useState(true); const [canExport, setCanExport] = useState(true);
const [alertPhone, setAlertPhone] = useState(""); const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false); const [alertsEnabled, setAlertsEnabled] = useState(false);
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
if (data) { if (data) {
setSettings(data); setSettings(data);
setEnabled(data.enabled); setEnabled(data.enabled);
setSessionDuration(data.session_duration_hours); setSessionDuration(data.sessionDurationHours);
setCanEdit(data.can_edit_entries); setCanEdit(data.canEditEntries);
setCanDelete(data.can_delete_entries); setCanDelete(data.canDeleteEntries);
setCanExport(data.can_export_csv); setCanExport(data.canExportCsv);
setAlertPhone(data.alert_phone ?? ""); setAlertPhone(data.alertPhone ?? "");
setAlertsEnabled(data.alerts_enabled ?? false); setAlertsEnabled(data.alertsEnabled);
} }
setLoading(false); setLoading(false);
}, []); }, []);
useEffect(() => { useEffect(() => {
const init = async () => { // Load on mount — setState-in-effect is intentional here
await loadSettings(); // because we're hydrating from server data.
}; // eslint-disable-next-line react-hooks/set-state-in-effect
init(); void loadSettings();
}, [loadSettings]); }, [loadSettings]);
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); 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); setSaving(true);
setMessage(null); setMessage(null);
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, { const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
enabled, enabled,
pin: pin || undefined, sessionDurationHours: sessionDuration,
session_duration_hours: sessionDuration, canEditEntries: canEdit,
can_edit_entries: canEdit, canDeleteEntries: canDelete,
can_delete_entries: canDelete, canExportCsv: canExport,
can_export_csv: canExport, alertPhone: alertPhone || null,
alert_phone: alertPhone || null, alertsEnabled,
alerts_enabled: alertsEnabled,
}); });
if (result.success) { if (result.success) {
setMessage({ type: "success", text: "Settings saved" }); setMessage({ type: "success", text: "Settings saved" });
setPin(""); if (result.settings) setSettings(result.settings);
setConfirmPin("");
} else { } else {
setMessage({ type: "error", text: result.error ?? "Save failed" }); setMessage({ type: "error", text: result.error ?? "Save failed" });
} }
setSaving(false); 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) { if (loading) {
return ( return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center"> <div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
<span className="text-zinc-500">Loading...</span> <span className="text-[#5a5d5a]">Loading</span>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-zinc-950"> <div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
{/* Header */} {/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4"> <div className="border-b border-[#d4d9d3] bg-white">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-2xl px-6 py-6">
<div className="flex items-center gap-3"> <p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 Settings</p>
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm"> Back</button> <h1
<div> className="mt-2 text-3xl font-medium text-[#1a4d2e]"
<h1 className="text-xl font-bold text-zinc-100">Water Log Admin Portal</h1> style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p> >
</div> Water Log · Admin Portal
</div> </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> </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"> <form onSubmit={handleSave} className="space-y-6">
{message && ( {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} {message.text}
</div> </div>
)} )}
{/* Enable toggle */} {/* Enable toggle */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5"> <Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
<div className="flex items-center justify-between"> <ToggleRow
<div> label="Enable"
<p className="font-semibold text-zinc-100">Enable Admin Portal</p> description="Allow water admins to sign in with a 4-digit PIN."
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p> value={enabled}
</div> onChange={setEnabled}
<button />
type="button" </Card>
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>
{enabled && ( {enabled && (
<> <>
{/* PIN */} {/* PIN */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4"> <Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
<p className="font-semibold text-zinc-100">4-Digit PIN</p> {revealedPin ? (
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
<div> New PIN write it down
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label> </p>
<input <div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
id="water-new-pin" <span
type="password" className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
inputMode="numeric" aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
pattern="[0-9]*" >
maxLength={4} {revealedPin}
value={pin} </span>
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>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setter((v: boolean) => !v)} onClick={() => setRevealedPin(null)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`} 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> </button>
</div> </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 */} {/* High/Low Alerts */}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4"> <Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
<p className="font-semibold text-zinc-100">High/Low Alerts</p> <ToggleRow
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate&apos;s thresholds.</p> label="Enable alerts"
description="Threshold breach notifications."
<div className="flex items-center justify-between"> value={alertsEnabled}
<div> onChange={setAlertsEnabled}
<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>
{alertsEnabled && ( {alertsEnabled && (
<div> <div className="mt-4">
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label> <label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
Alert phone number
</label>
<input <input
id="water-alert-phone" id="water-alert-phone"
type="tel" type="tel"
value={alertPhone} value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)} onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+1234567890" placeholder="+13035551234"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900" 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" 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>
)} )}
</div> </Card>
{/* 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>
</> </>
)} )}
<button <button
type="submit" type="submit"
disabled={saving} 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> </button>
</form> </form>
</div> </div>
</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 { NextResponse } from "next/server";
import { cookies } from "next/headers"; import { verifyWaterAdminPin } from "@/actions/water-log/settings";
import { pool } from "@/lib/db"; import { captureError } from "@/lib/sentry";
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { brandId, pin } = await request.json(); const { brandId, pin } = (await request.json()) as {
brandId?: string;
pin?: string;
};
if (!brandId || !pin) { 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 },
);
} }
const result = await verifyWaterAdminPin(brandId, pin);
// Get admin settings if (!result.success) {
const settingsRes = await pool.query<{ // Don't leak whether the PIN was wrong vs. not configured — both
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null; // are the same to a field attacker probing the endpoint.
}>( const status = result.error === "Invalid PIN" ? 401 : 403;
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`, return NextResponse.json(
[brandId], { success: false, error: "Invalid PIN" },
); { status },
const settings = settingsRes.rows[0]?.get_water_admin_settings; );
if (!settings?.enabled) {
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
} }
// 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 }); return NextResponse.json({ success: true });
} catch { } catch (err) {
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); 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 { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions"; 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) { export async function GET(request: NextRequest) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) { if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
if (!adminUser.can_manage_water_log) { if (!adminUser.can_manage_water_log) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const format = searchParams.get("format") ?? "json"; 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 raw = await getWaterEntries(brandId, 10000);
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; const rows: WaterLogReportRow[] = raw.map((r) => ({
logged_at: r.logged_at,
type WaterEntry = { headgate_name: r.headgate_name,
id: string; user_name: r.user_name,
user_id: string | null; user_role: "irrigator",
headgate_id: string | null; measurement: r.measurement,
measurement: number | null; unit: r.unit,
unit: string | null; notes: r.notes,
notes: string | null; submitted_via: r.submitted_via,
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 ?? [];
if (format === "csv") { if (format === "csv") {
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"]; const headers = [
const csvRows = [headers.join(",")]; "When",
for (const row of entries) { "Headgate",
csvRows.push([ "User",
row.id, "Measurement",
row.user_id ?? "", "Unit",
row.headgate_id ?? "", "Total Gallons",
row.measurement ?? "", "Method",
row.unit ?? "", "Notes",
`"${(row.notes ?? "").replace(/"/g, '""')}"`, "Via",
row.created_at ?? "", "Photo URL",
].join(",")); ];
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: { 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"`, "Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
}, },
}); });
} }
return NextResponse.json(entries); return NextResponse.json({ rows });
} }
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -361,8 +361,8 @@ export default function WaterAdminClient() {
setSavingUser(true); setSavingUser(true);
setNewUserError(null); setNewUserError(null);
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang); const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
if (result.success && result.pin) { if (result.success && result.pin && result.user) {
setNewPin({ name: result.irrigator!.name, pin: result.pin }); setNewPin({ name: result.user.name, pin: result.pin });
setNewUserName(""); setNewUserName("");
setShowAddUser(false); setShowAddUser(false);
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID); const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
+67 -32
View File
@@ -10,6 +10,7 @@ import {
setWaterLang, setWaterLang,
} from "@/actions/water-log/field"; } from "@/actions/water-log/field";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { WaterGauge } from "@/components/water/icons";
type Headgate = { type Headgate = {
id: string; id: string;
@@ -345,10 +346,13 @@ function WaterFieldInner() {
// Language selection screen // Language selection screen
if (step === "loading") { if (step === "loading") {
return ( return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6"> <div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center"> <div className="w-full max-w-xs text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" /> <div className="mx-auto mb-4 flex justify-center">
<p className="text-stone-500 text-base">Loading...</p> <WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div> </div>
</div> </div>
); );
@@ -357,20 +361,31 @@ function WaterFieldInner() {
// Language selection screen // Language selection screen
if (step === "lang") { if (step === "lang") {
return ( return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6"> <div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs"> <div className="w-full max-w-xs">
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1> <div className="mb-3 flex justify-center">
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p> <WaterGauge size={56} level={null} status="open" />
<div className="flex flex-col gap-4"> </div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button <button
onClick={() => handleLangSelect("en")} onClick={() => handleLangSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]" className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
> >
English English
</button> </button>
<button <button
onClick={() => handleLangSelect("es")} onClick={() => handleLangSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]" className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
> >
Español Español
</button> </button>
@@ -383,26 +398,31 @@ function WaterFieldInner() {
// Role selection screen // Role selection screen
if (step === "role") { if (step === "role") {
return ( return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6"> <div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs"> <div className="w-full max-w-xs">
<button <button
onClick={() => setStep("lang")} onClick={() => setStep("lang")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700" className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
> >
{t.back} {t.back}
</button> </button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1> <h1
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p> className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
<div className="flex flex-col gap-4"> style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button <button
onClick={() => { setStep("pin"); }} onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]" className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
> >
Irrigator Irrigator
</button> </button>
<button <button
onClick={() => { setStep("pin"); }} onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-stone-700 hover:bg-stone-800 min-h-[64px]" className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
> >
Admin Admin
</button> </button>
@@ -415,16 +435,21 @@ function WaterFieldInner() {
// PIN entry screen // PIN entry screen
if (step === "pin") { if (step === "pin") {
return ( return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6"> <div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs"> <div className="w-full max-w-xs">
<button <button
onClick={() => setStep("role")} onClick={() => setStep("role")}
className="text-sm text-stone-500 mb-6 hover:text-stone-700" className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
> >
{t.back} {t.back}
</button> </button>
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1> <h1
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p> className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={handlePinSubmit}> <form onSubmit={handlePinSubmit}>
<input <input
@@ -435,17 +460,17 @@ function WaterFieldInner() {
value={pin} value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))} onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder} placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4" className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }} style={{ letterSpacing: "0.3em" }}
autoFocus autoFocus
/> />
{error && ( {error && (
<p className="text-center text-red-600 text-sm mb-4">{error}</p> <p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)} )}
<button <button
type="submit" type="submit"
disabled={pin.length !== 4 || loading} disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 min-h-[64px]" className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
> >
{loading ? t.loggingIn : t.submit} {loading ? t.loggingIn : t.submit}
</button> </button>
@@ -459,17 +484,27 @@ function WaterFieldInner() {
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate); const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
return ( return (
<div className="min-h-screen bg-stone-50"> <div className="min-h-screen bg-[#fdfaf2]">
{/* Header */} {/* Header */}
<div className="bg-stone-900 px-4 py-4"> <div className="bg-[#1a4d2e] px-4 py-4">
<div className="mx-auto max-w-lg flex items-center justify-between"> <div className="mx-auto max-w-lg flex items-center justify-between">
<div> <div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-white">{t.title}</h1> <WaterGauge size={32} level={null} status="open" />
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p> <div>
<h1
className="text-xl font-bold text-white"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-xs text-[#bbf7d0] font-mono uppercase tracking-wider">
{t.loggedInAs}: {irrigatorName}
</p>
</div>
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]" className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
> >
{t.logout} {t.logout}
</button> </button>
@@ -477,7 +512,7 @@ function WaterFieldInner() {
</div> </div>
{/* Step progress indicator */} {/* Step progress indicator */}
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2"> <div className="bg-[#fafaf7] border-b border-[#d4d9d3] px-4 py-2">
<div className="mx-auto max-w-lg"> <div className="mx-auto max-w-lg">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{["lang", "role", "pin", "form"].map((s, i) => { {["lang", "role", "pin", "form"].map((s, i) => {
@@ -486,8 +521,8 @@ function WaterFieldInner() {
const isPast = stepIndex > i; const isPast = stepIndex > i;
return ( return (
<div key={s} className="flex items-center gap-1.5"> <div key={s} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} /> <div className={`w-2 h-2 rounded-full ${isActive ? "bg-[#1a4d2e]" : isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />} {i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
</div> </div>
); );
})} })}
+85
View File
@@ -0,0 +1,85 @@
/**
* SeasonMark — small inline sun / snowflake indicator used in the
* "Today's Summary" card to signal irrigation season status. Drawn as
* a flat 16px mark; uses the project's existing color tokens so it
* works in light and dark contexts.
*/
type Props = {
inSeason: boolean;
size?: number;
className?: string;
title?: string;
};
export function SeasonMark({
inSeason,
size = 16,
className = "",
title,
}: Props) {
const stroke = "#1a4d2e";
if (inSeason) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "In irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<circle cx="8" cy="8" r="2.8" fill="#facc15" stroke={stroke} strokeWidth="1" />
{[
[8, 1],
[8, 15],
[1, 8],
[15, 8],
[3, 3],
[13, 13],
[3, 13],
[13, 3],
].map(([cx, cy], i) => (
<line
key={i}
x1={8}
y1={8}
x2={cx}
y2={cy}
stroke={stroke}
strokeWidth="1"
strokeLinecap="round"
opacity="0.7"
/>
))}
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
className={className}
role="img"
aria-label={title ?? "Outside irrigation season"}
style={{ display: "inline-block", flexShrink: 0 }}
>
<g stroke="#1e3a8a" strokeWidth="1.2" strokeLinecap="round" fill="none">
<line x1="8" y1="1" x2="8" y2="15" />
<line x1="1" y1="8" x2="15" y2="8" />
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
<line x1="2.5" y1="13.5" x2="13.5" y2="2.5" />
{/* Arrow heads */}
<polyline points="6.5,2.5 8,1 9.5,2.5" />
<polyline points="6.5,13.5 8,15 9.5,13.5" />
<polyline points="2.5,6.5 1,8 2.5,9.5" />
<polyline points="13.5,6.5 15,8 13.5,9.5" />
<polyline points="4,4 2.5,2.5 4,1" />
<polyline points="12,4 13.5,2.5 12,1" />
<polyline points="4,12 2.5,13.5 4,15" />
<polyline points="12,12 13.5,13.5 12,15" />
</g>
</svg>
);
}
+128
View File
@@ -0,0 +1,128 @@
/**
* WaterGauge — a stylized "ditch gauge" SVG used as a visual signature
* for the Water Log feature. Renders a vertical pipe with a water level
* and a small headgate at the top. Deliberately drawn with a slight
* hand-sketched quality (rough strokes, no perfectly straight lines)
* to evoke a field-notebook annotation rather than a slick dashboard
* chart.
*
* <WaterGauge size={48} level={0.7} status="open" />
* <WaterGauge size={24} level={null} status="closed" />
*/
type Props = {
size?: number;
level?: number | null; // 0..1
status?: "open" | "closed" | "maintenance";
className?: string;
ariaLabel?: string;
};
export function WaterGauge({
size = 32,
level = 0.5,
status = "open",
className = "",
ariaLabel = "Water gauge",
}: Props) {
const w = size;
const h = size * 1.25;
const stroke = Math.max(1, size / 16);
// Clamp + choose fill level
const l =
level == null
? status === "closed"
? 0
: 0.45
: Math.max(0, Math.min(1, level));
// Color reflects the operational state
const waterColor =
status === "maintenance"
? "#a16207" // gold-700
: status === "closed"
? "#86868b" // surface-400
: l > 0.85
? "#dc2626" // red — over threshold
: l < 0.15
? "#2563eb" // blue — under threshold
: "#0e7490"; // cyan-700 — flowing nicely
const headgateColor = status === "closed" ? "#3b3b3f" : "#1a4d2e";
return (
<svg
width={w}
height={h}
viewBox="0 0 32 40"
role="img"
aria-label={ariaLabel}
className={className}
style={{ display: "inline-block", flexShrink: 0 }}
>
{/* Top "headgate" — a small handled gate */}
<g stroke={headgateColor} strokeWidth={stroke} fill="none" strokeLinecap="round">
{/* Gate top bar */}
<path d="M9 4 H23" />
{/* Gate handle */}
<path d="M14 1.5 V4 M18 1.5 V4" />
{/* Gate frame */}
<path d="M8 4 V8 H24 V4" />
{/* Gate wheel */}
<circle cx="16" cy="3" r="1.2" fill={headgateColor} />
</g>
{/* The ditch / pipe */}
<g
stroke="#1a1a1a"
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 8 L8 36 L24 36 L24 8" />
{/* Tick marks at quarter / half / three-quarter */}
<path d="M8 17 H11 M8 24 H11 M8 31 H11" />
<path d="M24 17 H21 M24 24 H21 M24 31 H21" />
</g>
{/* Water level — clipped inside the pipe */}
<clipPath id={`wg-clip-${Math.round(size)}-${status}`}>
<path d="M9.2 9 L9.2 35.2 L22.8 35.2 L22.8 9 Z" />
</clipPath>
<g clipPath={`url(#wg-clip-${Math.round(size)}-${status})`}>
<rect
x={9.2}
y={35.2 - 25.6 * l}
width={13.6}
height={25.6 * l}
fill={waterColor}
opacity={0.85}
/>
{/* Wavy water surface — three short arcs */}
{l > 0 && (
<path
d={`M9.2 ${35.2 - 25.6 * l} q1.7 -1 3.4 0 t3.4 0 t3.4 0 t3.4 0`}
stroke={waterColor}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
)}
</g>
{/* Status dot — top-right corner */}
<circle
cx={26}
cy={3}
r={1.4}
fill={
status === "open"
? "#16a34a" // green
: status === "closed"
? "#9ca3af" // gray
: "#ca8a04" // gold
}
/>
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { WaterGauge } from "./WaterGauge";
export { SeasonMark } from "./SeasonMark";
+82
View File
@@ -0,0 +1,82 @@
/**
* 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);
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Water Log — PIN hashing.
*
* PINs in TYLER OS are 4-digit numerics that field workers type on a wet
* phone screen. That makes them both *very* low-entropy and *very*
* frequently entered. A slow KDF (scrypt) gives us a defense-in-depth
* layer: even if the database leaks, an attacker still has to do real
* work per guess.
*
* Format: `scrypt$N$r$p$salt_b64$hash_b64` — self-describing so we can
* upgrade the parameters later without forking the data. No third-party
* deps; Node's built-in `crypto.scryptSync` is plenty.
*/
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const N = 16384; // CPU/memory cost
const r = 8;
const p = 1;
const KEY_LEN = 32;
const SALT_LEN = 16;
export const PIN_MIN = 4;
export const PIN_MAX = 8;
/**
* Validate raw PIN format. Returns null on success, an error string on failure.
* Used by API entry points and unit tests alike.
*/
export function validatePin(rawPin: unknown): string | null {
if (typeof rawPin !== "string") return "PIN must be a string";
if (rawPin.length < PIN_MIN || rawPin.length > PIN_MAX) {
return `PIN must be ${PIN_MIN}${PIN_MAX} digits`;
}
if (!/^\d+$/.test(rawPin)) return "PIN must be numeric";
// Catch obviously weak PINs (1111, 1234, 0000, …). These are still
// possible to use, but we warn. We don't reject — the customer
// explicitly asked for simple PINs.
return null;
}
/** True if the PIN is "weak" (all-same, monotonic, or palindromic). */
export function isWeakPin(rawPin: string): boolean {
if (/^(.)\1+$/.test(rawPin)) return true; // 1111, 2222
if ("0123456789".includes(rawPin)) return true; // 1234, 5678
if ("9876543210".includes(rawPin)) return true; // 4321, 8765
if (rawPin === rawPin.split("").reverse().join("")) return true; // 1221
return false;
}
export function hashPin(rawPin: string): string {
const salt = randomBytes(SALT_LEN);
const hash = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N,
r,
p,
});
return `scrypt$${N}$${r}$${p}$${salt.toString("base64")}$${hash.toString(
"base64",
)}`;
}
export function verifyPin(rawPin: string, stored: string): boolean {
if (typeof stored !== "string" || !stored.startsWith("scrypt$")) {
return false;
}
const parts = stored.split("$");
if (parts.length !== 6) return false;
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
const n = parseInt(nStr, 10);
const r = parseInt(rStr, 10);
const p = parseInt(pStr, 10);
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) {
return false;
}
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(saltB64, "base64");
expected = Buffer.from(hashB64, "base64");
} catch {
return false;
}
if (expected.length !== KEY_LEN) return false;
let candidate: Buffer;
try {
candidate = scryptSync(rawPin.normalize("NFKC"), salt, KEY_LEN, {
N: n,
r,
p,
});
} catch {
return false;
}
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}
/** Generate a random 4-digit PIN. Used by the create-user action. */
export function generatePin(): string {
// Reject obviously weak PINs at generation time so the admin never has
// to hand a brand-new worker a PIN of "1234".
for (let attempt = 0; attempt < 16; attempt++) {
const candidate = (
Math.floor(Math.random() * 10000) + 0
)
.toString()
.padStart(4, "0");
if (!isWeakPin(candidate)) return candidate;
}
// Fallback: still return something — this branch is essentially impossible
return Math.floor(Math.random() * 10000)
.toString()
.padStart(4, "0");
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Tests for `src/lib/water-log-pin.ts` — PIN hashing/verification used
* by the field portal. The hashing layer is critical: a 4-digit PIN is
* essentially a 10,000-entry dictionary, so a slow KDF and a per-row
* random salt are the only defense-in-depth that matters if the
* database leaks.
*
* The `server-only` import is stubbed so the test runner can import
* the module (the pattern matches `tests/unit/passwords.test.ts`).
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("server-only", () => ({}));
import {
hashPin,
verifyPin,
validatePin,
isWeakPin,
generatePin,
PIN_MIN,
PIN_MAX,
} from "@/lib/water-log-pin";
describe("validatePin", () => {
it("accepts a 4-digit numeric PIN", () => {
expect(validatePin("1234")).toBeNull();
});
it("accepts the full 8-digit range", () => {
expect(validatePin("12345678")).toBeNull();
});
it("rejects pins shorter than PIN_MIN", () => {
expect(validatePin("12")).toMatch(/4.*8/);
});
it("rejects pins longer than PIN_MAX", () => {
expect(validatePin("123456789")).toMatch(/4.*8/);
});
it("rejects non-numeric input", () => {
expect(validatePin("123a")).toBe("PIN must be numeric");
});
it("rejects non-string types", () => {
expect(validatePin(1234)).toBe("PIN must be a string");
expect(validatePin(null)).toBe("PIN must be a string");
expect(validatePin(undefined)).toBe("PIN must be a string");
});
it("accepts leading zeros", () => {
expect(validatePin("0007")).toBeNull();
});
});
describe("isWeakPin", () => {
it("flags repeated digits", () => {
expect(isWeakPin("1111")).toBe(true);
expect(isWeakPin("0000")).toBe(true);
});
it("flags monotonic runs", () => {
expect(isWeakPin("1234")).toBe(true);
expect(isWeakPin("4321")).toBe(true);
});
it("flags palindromes", () => {
expect(isWeakPin("1221")).toBe(true);
expect(isWeakPin("3443")).toBe(true);
});
it("does not flag random-looking pins", () => {
expect(isWeakPin("4739")).toBe(false);
expect(isWeakPin("8201")).toBe(false);
});
});
describe("hashPin / verifyPin", () => {
it("round-trips a 4-digit PIN", () => {
const h = hashPin("4739");
expect(verifyPin("4739", h)).toBe(true);
expect(verifyPin("4740", h)).toBe(false);
});
it("uses a random salt — same PIN produces different hashes", () => {
const a = hashPin("4739");
const b = hashPin("4739");
expect(a).not.toBe(b);
expect(verifyPin("4739", a)).toBe(true);
expect(verifyPin("4739", b)).toBe(true);
});
it("uses a self-describing format (scrypt$N$r$p$salt$hash)", () => {
const h = hashPin("4739");
const parts = h.split("$");
expect(parts[0]).toBe("scrypt");
expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0);
expect(Number.parseInt(parts[2], 10)).toBeGreaterThan(0);
expect(Number.parseInt(parts[3], 10)).toBeGreaterThan(0);
// Salt is 16 bytes base64 — length depends on padding but always > 16
expect(parts[4].length).toBeGreaterThanOrEqual(16);
// Hash is 32 bytes base64
expect(parts[5].length).toBeGreaterThanOrEqual(40);
});
it("returns false (no exception) for tampered hashes", () => {
expect(verifyPin("4739", "not-a-real-hash")).toBe(false);
expect(verifyPin("4739", "scrypt$1$1$1$xx$yy")).toBe(false);
expect(verifyPin("4739", "")).toBe(false);
});
it("returns false for the wrong PIN (without revealing why)", () => {
const h = hashPin("4739");
expect(verifyPin("0000", h)).toBe(false);
expect(verifyPin("9999", h)).toBe(false);
});
it("rejects the empty string (no bypass for missing PIN)", () => {
const h = hashPin("4739");
expect(verifyPin("", h)).toBe(false);
});
});
describe("generatePin", () => {
it("returns a 4-digit string", () => {
const pin = generatePin();
expect(pin).toMatch(/^\d{4}$/);
});
it("avoids obvious weak patterns (no 1111, 1234, etc.)", () => {
// 1000 attempts — should never see a weak pin if the generator works.
for (let i = 0; i < 1000; i++) {
const pin = generatePin();
expect(isWeakPin(pin)).toBe(false);
}
});
it("produces variety across many calls (statistical)", () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i++) {
seen.add(generatePin());
}
// With 10k possible 4-digit pins, we'd expect to see well over 100
// distinct values in 200 draws. This is a sanity check, not a hard
// guarantee — if it ever flakes, increase the iteration count.
expect(seen.size).toBeGreaterThan(80);
});
});
describe("PIN length constants", () => {
it("PINs are 48 digits", () => {
expect(PIN_MIN).toBe(4);
expect(PIN_MAX).toBe(8);
});
});
+278
View File
@@ -0,0 +1,278 @@
/**
* Tests for `src/lib/water-log-reporting.ts` — pure-function utilities
* used by both the admin and field clients. Covers:
* - irrigation season detection (in/out + year-wrapping)
* - entry shaping for the report format
* - filter by date range, headgate, user, submission source
* - CSV encoding (commas, quotes, newlines, unicode)
* - report formatter ("Daily Report" output for SMS/email)
*/
import { describe, it, expect } from "vitest";
import {
isInIrrigationSeason,
shapeWaterLogEntry,
filterWaterLogEntries,
waterLogToCSV,
formatDailyWaterReport,
getDisplayAgeLabel,
getDisplayAgeColor,
type IrrigationSeasonSettings,
type WaterLogReportRow,
} from "@/lib/water-log-reporting";
const ROW = (overrides: Partial<WaterLogReportRow> = {}): WaterLogReportRow => ({
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "irrigator",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
...overrides,
});
describe("isInIrrigationSeason", () => {
const season: IrrigationSeasonSettings = {
seasonStartMonth: 3,
seasonStartDay: 15,
seasonEndMonth: 10,
seasonEndDay: 15,
};
// Construct dates in *local* time (year, monthIndex, day) so the test
// is timezone-independent. `new Date("2026-03-15")` would parse as
// UTC midnight, which on a US TZ tester becomes the previous day
// locally and makes boundary assertions brittle.
const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d);
it("returns true inside the season (May 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 5, 1), season)).toBe(true);
});
it("returns false before the season (Feb 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 2, 1), season)).toBe(false);
});
it("returns false after the season (Dec 1)", () => {
expect(isInIrrigationSeason(localDate(2026, 12, 1), season)).toBe(false);
});
it("returns true on the boundary day (Mar 15)", () => {
expect(isInIrrigationSeason(localDate(2026, 3, 15), season)).toBe(true);
});
it("returns true on the closing boundary (Oct 15)", () => {
expect(isInIrrigationSeason(localDate(2026, 10, 15), season)).toBe(true);
});
it("returns false the day after closing (Oct 16)", () => {
expect(isInIrrigationSeason(localDate(2026, 10, 16), season)).toBe(false);
});
it("handles a season that wraps the year (Nov → Feb)", () => {
const wrap: IrrigationSeasonSettings = {
seasonStartMonth: 11,
seasonStartDay: 1,
seasonEndMonth: 2,
seasonEndDay: 28,
};
expect(isInIrrigationSeason(localDate(2026, 12, 25), wrap)).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 1, 15), wrap)).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 6, 1), wrap)).toBe(false);
});
it("uses default settings (Mar 15 → Oct 15) when none provided", () => {
expect(isInIrrigationSeason(localDate(2026, 4, 1))).toBe(true);
expect(isInIrrigationSeason(localDate(2026, 12, 1))).toBe(false);
});
});
describe("shapeWaterLogEntry", () => {
it("normalizes an entry, providing a default role", () => {
const out = shapeWaterLogEntry({
id: "x",
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "water_admin",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
expect(out).toEqual({
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
user_role: "water_admin",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
});
it("defaults role to 'irrigator' when missing", () => {
const out = shapeWaterLogEntry({
id: "x",
logged_at: "2026-06-15T14:30:00.000Z",
headgate_name: "Upper Ditch",
user_name: "Tyler",
measurement: 3.2,
unit: "CFS",
notes: null,
submitted_via: "field",
});
expect(out.user_role).toBe("irrigator");
});
});
describe("filterWaterLogEntries", () => {
const rows: WaterLogReportRow[] = [
ROW({ logged_at: "2026-06-10T10:00:00.000Z", headgate_name: "A", user_name: "Tyler" }),
ROW({ logged_at: "2026-06-15T10:00:00.000Z", headgate_name: "B", user_name: "Tyler" }),
ROW({ logged_at: "2026-06-20T10:00:00.000Z", headgate_name: "A", user_name: "Sam" }),
];
it("returns everything when no filters", () => {
expect(filterWaterLogEntries(rows, {})).toHaveLength(3);
});
it("filters by dateFrom (inclusive of full day)", () => {
const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15" });
expect(out).toHaveLength(2);
});
it("filters by dateTo (inclusive of full day)", () => {
const out = filterWaterLogEntries(rows, { dateTo: "2026-06-15" });
expect(out).toHaveLength(2);
});
it("filters by date range", () => {
const out = filterWaterLogEntries(rows, {
dateFrom: "2026-06-15",
dateTo: "2026-06-15",
});
expect(out).toHaveLength(1);
});
it("filters by headgate", () => {
const out = filterWaterLogEntries(rows, { headgateId: "A" });
expect(out).toHaveLength(2);
});
it("filters by user", () => {
const out = filterWaterLogEntries(rows, { userId: "Sam" });
expect(out).toHaveLength(1);
});
it("filters by submission source", () => {
const rows2 = [
ROW({ submitted_via: "field" }),
ROW({ submitted_via: "admin" }),
];
expect(filterWaterLogEntries(rows2, { submittedVia: "admin" })).toHaveLength(1);
});
it("combines multiple filters", () => {
const out = filterWaterLogEntries(rows, {
dateFrom: "2026-06-15",
headgateId: "A",
});
expect(out).toHaveLength(1);
expect(out[0].user_name).toBe("Sam");
});
});
describe("waterLogToCSV", () => {
it("emits a header row + one row per entry", () => {
const csv = waterLogToCSV([
ROW(),
ROW({ measurement: 4.5, notes: "rain" }),
]);
const lines = csv.split("\n");
expect(lines).toHaveLength(3);
expect(lines[0]).toContain("When");
expect(lines[0]).toContain("Headgate");
});
it("quotes fields containing commas, quotes, or newlines", () => {
const csv = waterLogToCSV([
ROW({ notes: 'has, "quotes" and\nnewlines' }),
]);
// The cell value is properly quoted with internal quotes doubled.
// An embedded newline splits the row across two physical lines,
// so we read the full CSV string rather than a single line.
expect(csv).toContain('"has, ""quotes"" and\nnewlines"');
});
it("escapes unicode and emoji", () => {
const csv = waterLogToCSV([
ROW({ user_name: "José 🚜" }),
]);
expect(csv).toContain("José 🚜");
});
it("emits a syntactically valid CSV (parses with a basic split)", () => {
const csv = waterLogToCSV([ROW(), ROW()]);
// 1 header + 2 data rows
expect(csv.split("\n")).toHaveLength(3);
});
});
describe("formatDailyWaterReport", () => {
it("returns null when no rows and sendEvenIfEmpty is false", () => {
expect(formatDailyWaterReport([])).toBeNull();
});
it("emits an empty-day message when sendEvenIfEmpty is true", () => {
const out = formatDailyWaterReport([], { sendEvenIfEmpty: true });
expect(out).toContain("No entries reported today");
});
it("includes the recipient name when provided", () => {
const out = formatDailyWaterReport([ROW()], { recipientName: "David" });
// We don't enforce the salutation format, but the row must be present.
expect(out).toContain("Tyler");
expect(out).toContain("Upper Ditch");
});
it("sums the total measurement", () => {
const out = formatDailyWaterReport([
ROW({ measurement: 2 }),
ROW({ measurement: 3.5 }),
]);
expect(out).toContain("5.50");
});
it("includes notes when present", () => {
const out = formatDailyWaterReport([ROW({ notes: "Ditch running heavy" })]);
expect(out).toContain("Ditch running heavy");
});
});
describe("display age helpers", () => {
it("returns 'never' for null", () => {
expect(getDisplayAgeLabel(null)).toBe("never");
expect(getDisplayAgeColor(null)).toBe("red");
});
it("returns 'just now' under 1 minute", () => {
expect(getDisplayAgeLabel(0.5)).toBe("just now");
expect(getDisplayAgeColor(0.5)).toBe("green");
});
it("buckets by minutes / hours / days", () => {
expect(getDisplayAgeLabel(15)).toBe("15m ago");
expect(getDisplayAgeLabel(90)).toBe("1h ago");
expect(getDisplayAgeLabel(120)).toBe("2h ago");
expect(getDisplayAgeLabel(60 * 26)).toBe("1d ago");
});
it("colors by recency", () => {
expect(getDisplayAgeColor(15)).toBe("green"); // < 30m
expect(getDisplayAgeColor(60)).toBe("yellow"); // < 2h
expect(getDisplayAgeColor(180)).toBe("red"); // > 2h
});
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Water Log — end-to-end tests.
*
* These cover the user-visible surface of the Water Log feature across
* both PIN-authenticated portals and the site-admin view. The intent is
* to catch the most common production regressions: page no longer
* renders, PIN screen broken, admin gate missing, CSV export leaking
* unauthenticated data, etc.
*
* Heavy multi-actor flows (admin adds headgate → irrigator submits entry
* → admin exports CSV) require a working DB + a fully-provisioned
* Tuxedo brand, so they're behind a `WATER_LOG_E2E_DB=1` env flag. The
* default suite stays runnable against any deployment.
*
* Run locally:
* PLAYWRIGHT_URL=http://localhost:3000 npx playwright test water-log
* # Or against prod (default in playwright.config):
* npx playwright test water-log
*/
import { test, expect, request } from "@playwright/test";
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
test.describe("Water Log — public surfaces", () => {
test("/water renders the PIN entry form", async ({ page }) => {
const res = await page.goto(`${BASE}/water`);
expect(res?.status()).toBeLessThan(500);
// PIN input is a password input with inputMode=numeric
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
await expect(pin).toBeVisible({ timeout: 10_000 });
});
test("/water PIN input strips non-digits and caps at 4 chars", async ({ page }) => {
await page.goto(`${BASE}/water`);
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
await expect(pin).toBeVisible({ timeout: 10_000 });
await pin.fill("12ab34cd56ef");
const value = await pin.inputValue();
expect(value).toMatch(/^\d{0,4}$/);
expect(value.length).toBeLessThanOrEqual(4);
});
test("/water/admin renders the admin PIN entry form", async ({ page }) => {
const res = await page.goto(`${BASE}/water/admin/login`);
expect(res?.status()).toBeLessThan(500);
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
await expect(pin).toBeVisible({ timeout: 10_000 });
});
test("/water/admin wrong PIN shows an error, does not navigate", async ({ page }) => {
await page.goto(`${BASE}/water/admin/login`);
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
await expect(pin).toBeVisible({ timeout: 10_000 });
// Use a clearly-invalid 4-digit PIN. We don't know the configured
// PIN, so 0000 is the canonical "wrong" value.
await pin.fill("0000");
const submit = page.getByRole("button", { name: /sign in|submit|enter/i }).first();
if (await submit.isVisible().catch(() => false)) {
await submit.click();
} else {
await pin.press("Enter");
}
// After a wrong PIN, the URL should still be the login page.
await page.waitForTimeout(500);
expect(page.url()).toMatch(/\/water\/admin\/login/);
});
});
test.describe("Water Log — site-admin gate", () => {
test("/admin/water-log redirects unauthenticated users", async ({ page }) => {
await page.goto(`${BASE}/admin/water-log`, { waitUntil: "domcontentloaded" });
// Should not stay on /admin/water-log if not signed in.
expect(page.url()).not.toMatch(/\/admin\/water-log$/);
});
test("/admin/water-log/settings redirects unauthenticated users", async ({ page }) => {
await page.goto(`${BASE}/admin/water-log/settings`, { waitUntil: "domcontentloaded" });
expect(page.url()).not.toMatch(/\/admin\/water-log\/settings$/);
});
test("/admin/water-log/headgates redirects unauthenticated users", async ({ page }) => {
await page.goto(`${BASE}/admin/water-log/headgates`, { waitUntil: "domcontentloaded" });
expect(page.url()).not.toMatch(/\/admin\/water-log\/headgates$/);
});
test("/admin/water-log/users redirects unauthenticated users", async ({ page }) => {
await page.goto(`${BASE}/admin/water-log/users`, { waitUntil: "domcontentloaded" });
expect(page.url()).not.toMatch(/\/admin\/water-log\/users$/);
});
test("/admin/water-log/entries redirects unauthenticated users", async ({ page }) => {
await page.goto(`${BASE}/admin/water-log/entries`, { waitUntil: "domcontentloaded" });
expect(page.url()).not.toMatch(/\/admin\/water-log\/entries$/);
});
});
test.describe("Water Log — API auth", () => {
test("/api/water-logs/export returns 401/403 for unauthenticated callers", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.get("/api/water-logs/export");
// Either 401 (no admin session) or 403 (signed in but not permitted)
// — both are correct, the point is the endpoint doesn't leak data.
expect([401, 403]).toContain(res.status());
await ctx.dispose();
});
test("/api/water-admin-auth rejects malformed PINs (length != 4)", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.post("/api/water-admin-auth", {
data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "12" },
});
// 400 (bad request) or 401/403 (no admin user) — both are valid
// gates. Critically, it must NOT be 200.
expect(res.status()).not.toBe(200);
await ctx.dispose();
});
test("/api/water-admin-auth rejects non-numeric PINs", async () => {
const ctx = await request.newContext({ baseURL: BASE });
const res = await ctx.post("/api/water-admin-auth", {
data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "abcd" },
});
expect(res.status()).not.toBe(200);
await ctx.dispose();
});
});
test.describe("Water Log — full workflow (DB required)", () => {
// These tests need a live database with the Tuxedo brand provisioned
// and the migrations from 0090 applied. Skipped by default so the
// suite still runs against any deployment. Set WATER_LOG_E2E_DB=1
// (and ensure BASE points at a dev DB) to opt in.
test.skip(!process.env.WATER_LOG_E2E_DB, "requires WATER_LOG_E2E_DB=1 + a live DB");
test("site admin can add a headgate and an irrigator, then export CSV", async ({ page }) => {
// 1. Sign in as a platform_admin or Tuxedo brand_admin
await page.goto(`${BASE}/login`);
// The login flow is environment-specific (Google OAuth, email link,
// or local dev session). Try the dev session form first.
const emailInput = page.getByLabel(/email/i).first();
if (await emailInput.isVisible().catch(() => false)) {
await emailInput.fill(process.env.WATER_LOG_TEST_ADMIN_EMAIL ?? "admin@tuxedo.example");
const passwordInput = page.getByLabel(/password/i).first();
if (await passwordInput.isVisible().catch(() => false)) {
await passwordInput.fill(process.env.WATER_LOG_TEST_ADMIN_PASSWORD ?? "test-password");
}
await page.getByRole("button", { name: /sign in/i }).first().click();
}
// 2. Navigate to the Water Log admin
await page.goto(`${BASE}/admin/water-log`);
await expect(page.getByRole("heading", { name: /water log/i }).first()).toBeVisible({
timeout: 15_000,
});
// 3. Add a headgate
const hgName = `E2E Headgate ${Date.now()}`;
const addHg = page.getByRole("button", { name: /add headgate/i }).first();
if (await addHg.isVisible().catch(() => false)) {
await addHg.click();
const nameInput = page.getByPlaceholder(/north field gate|gate/i).first();
await nameInput.fill(hgName);
await page.getByRole("button", { name: /^save|create|add$/i }).first().click();
}
await expect(page.getByText(hgName).first()).toBeVisible({ timeout: 10_000 });
// 4. Add an irrigator
const irrName = `E2E Irrigator ${Date.now()}`;
const addUser = page.getByRole("button", { name: /add (water )?user|add irrigator/i }).first();
if (await addUser.isVisible().catch(() => false)) {
await addUser.click();
const nameInput = page.getByPlaceholder(/full name/i).first();
await nameInput.fill(irrName);
await page.getByRole("button", { name: /^save|create|add$/i }).first().click();
}
await expect(page.getByText(irrName).first()).toBeVisible({ timeout: 10_000 });
// 5. Hit the CSV export endpoint and confirm it returns CSV
const ctx = await request.newContext({
baseURL: BASE,
storageState: await page.context().storageState(),
});
const res = await ctx.get("/api/water-logs/export?format=csv");
expect(res.status()).toBe(200);
const contentType = res.headers()["content-type"] ?? "";
expect(contentType).toContain("text/csv");
const body = await res.text();
expect(body.split("\n").length).toBeGreaterThanOrEqual(1);
await ctx.dispose();
});
});