diff --git a/.env.example b/.env.example index 4ce73cd..4679ec6 100644 --- a/.env.example +++ b/.env.example @@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs # ── Cron / automation ─────────────────────────────────────────────────────── 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. diff --git a/README.md b/README.md index f085eb6..c623929 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`: - **Communications** — Harvest Reach campaign manager - **Wholesale** — Wholesale portal settings - **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 ## Email Automations (Harvest Reach) diff --git a/db/migrations/0090_water_log_completion.sql b/db/migrations/0090_water_log_completion.sql new file mode 100644 index 0000000..99c6eb9 --- /dev/null +++ b/db/migrations/0090_water_log_completion.sql @@ -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()); diff --git a/db/schema/water-log.ts b/db/schema/water-log.ts index b58f3b6..9be0ac6 100644 --- a/db/schema/water-log.ts +++ b/db/schema/water-log.ts @@ -1,5 +1,15 @@ /** - * Water Log. Source: `db/migrations/0001_init.sql`. + * Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`. + * + * Six tables, all brand-scoped with RLS: + * - water_headgates — physical gates a measurement is tied to + * - water_irrigators — PIN-authenticated field workers + * - water_sessions — short-lived PIN sessions for irrigators + * - water_log_entries — the actual reading logs + * - water_alert_log — high/low threshold alert history + * - water_admin_settings — per-brand admin PIN + alert config + * - water_admin_sessions — admin sign-in sessions (separate cookie) + * - water_audit_log — who changed what, when */ import { pgTable, @@ -8,7 +18,13 @@ import { numeric, boolean, timestamp, + date, + jsonb, + doublePrecision, index, + uniqueIndex, + integer, + check, } from "drizzle-orm/pg-core"; import { brands } from "./brands"; import { adminUsers } from "./brands"; @@ -21,11 +37,29 @@ export const waterHeadgates = pgTable( .notNull() .references(() => brands.id, { onDelete: "cascade" }), name: text("name").notNull(), + /** Per-headgate opaque token used in the QR code. */ + headgateToken: text("headgate_token").notNull().unique(), + /** Open / Closed / Maintenance. */ + status: text("status").notNull().default("open"), + /** Display unit: CFS, GPM, Inches, AF/Day, etc. */ + unit: text("unit").notNull().default("CFS"), + /** Optional max-flow marker in GPM. */ + maxFlowGpm: numeric("max_flow_gpm"), + /** High-water alert threshold (units match `unit`). */ + highThreshold: numeric("high_threshold"), + /** Low-water alert threshold (units match `unit`). */ + lowThreshold: numeric("low_threshold"), + notes: text("notes"), active: boolean("active").notNull().default(true), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, + (t) => ({ + brandIdx: index("water_headgates_brand_idx").on(t.brandId), + tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken), + }), ); export const waterIrrigators = pgTable( @@ -40,12 +74,19 @@ export const waterIrrigators = pgTable( languagePreference: text("language_preference") .notNull() .default("en"), + /** "irrigator" submits entries only, "water_admin" can manage the brand. */ + role: text("role").notNull().default("irrigator"), + phone: text("phone"), + notes: text("notes"), active: boolean("active").notNull().default(true), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }, + (t) => ({ + brandIdx: index("water_irrigators_brand_idx").on(t.brandId), + }), ); export const waterSessions = pgTable( @@ -75,10 +116,20 @@ export const waterLogEntries = pgTable( irrigatorId: uuid("irrigator_id") .notNull() .references(() => waterIrrigators.id, { onDelete: "cascade" }), + /** Raw measurement value, in the entry's `unit`. */ measurement: numeric("measurement").notNull(), unit: text("unit").notNull(), + /** "manual" | "meter" | "estimate" | "qr" */ + method: text("method").notNull().default("manual"), + /** Optional auto-computed total (in gallons) when CFS × duration is known. */ + totalGallons: numeric("total_gallons"), notes: text("notes"), submittedVia: text("submitted_via").notNull().default("app"), + photoUrl: text("photo_url"), + latitude: doublePrecision("latitude"), + longitude: doublePrecision("longitude"), + /** Date-only mirror of loggedAt for fast grouping / dashboard queries. */ + loggedDate: date("logged_date"), loggedAt: timestamp("logged_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -87,6 +138,14 @@ export const waterLogEntries = pgTable( (t) => ({ brandIdx: index("water_log_entries_brand_idx").on(t.brandId), headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId), + brandDateIdx: index("water_log_entries_brand_date_idx").on( + t.brandId, + t.loggedDate, + ), + irrigatorIdx: index("water_log_entries_irrigator_idx").on( + t.irrigatorId, + t.loggedAt, + ), }), ); @@ -98,7 +157,9 @@ export const waterAlertLog = pgTable( .notNull() .references(() => brands.id, { onDelete: "cascade" }), alertType: text("alert_type").notNull(), - headgateId: uuid("headgate_id").references(() => waterHeadgates.id), + headgateId: uuid("headgate_id").references(() => waterHeadgates.id, { + onDelete: "set null", + }), message: text("message").notNull(), sentTo: text("sent_to"), sentAt: timestamp("sent_at", { withTimezone: true }), @@ -108,8 +169,89 @@ export const waterAlertLog = pgTable( }, ); +export const waterAdminSettings = pgTable("water_admin_settings", { + brandId: uuid("brand_id") + .primaryKey() + .references(() => brands.id, { onDelete: "cascade" }), + /** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */ + pinHash: text("pin_hash"), + enabled: boolean("enabled").notNull().default(true), + sessionDurationHours: integer("session_duration_hours") + .notNull() + .default(4), + canEditEntries: boolean("can_edit_entries").notNull().default(true), + canDeleteEntries: boolean("can_delete_entries").notNull().default(true), + canExportCsv: boolean("can_export_csv").notNull().default(true), + alertPhone: text("alert_phone"), + alertsEnabled: boolean("alerts_enabled").notNull().default(false), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedBy: uuid("updated_by").references(() => adminUsers.id), +}); + +export const waterAdminSessions = pgTable( + "water_admin_sessions", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + adminUserId: uuid("admin_user_id") + .notNull() + .references(() => adminUsers.id, { onDelete: "cascade" }), + pinHashUsed: text("pin_hash_used").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + adminIdx: index("water_admin_sessions_admin_idx").on( + t.adminUserId, + t.expiresAt, + ), + }), +); + +export const waterAuditLog = pgTable( + "water_audit_log", + { + id: uuid("id").primaryKey().defaultRandom(), + brandId: uuid("brand_id") + .notNull() + .references(() => brands.id, { onDelete: "cascade" }), + actorId: uuid("actor_id").references(() => adminUsers.id), + actorLabel: text("actor_label").notNull(), + action: text("action").notNull(), + entityType: text("entity_type").notNull(), + entityId: uuid("entity_id"), + details: jsonb("details"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + brandRecentIdx: index("water_audit_log_brand_recent_idx").on( + t.brandId, + t.createdAt, + ), + }), +); + +// ── Inferred types (used by every action and client component) ──────────── + export type WaterHeadgate = typeof waterHeadgates.$inferSelect; +export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert; + export type WaterIrrigator = typeof waterIrrigators.$inferSelect; +export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert; + export type WaterSession = typeof waterSessions.$inferSelect; export type WaterLogEntry = typeof waterLogEntries.$inferSelect; +export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert; + export type WaterAlertLog = typeof waterAlertLog.$inferSelect; +export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect; +export type WaterAdminSession = typeof waterAdminSessions.$inferSelect; +export type WaterAuditLog = typeof waterAuditLog.$inferSelect; diff --git a/docs/water-log.md b/docs/water-log.md new file mode 100644 index 0000000..d213ad8 --- /dev/null +++ b/docs/water-log.md @@ -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 (1–168 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 +- 4–8 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 1–168 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 Log–specific 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 1–168 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 diff --git a/src/actions/water-log/admin.ts b/src/actions/water-log/admin.ts index f1cc4e8..9ebfc0a 100644 --- a/src/actions/water-log/admin.ts +++ b/src/actions/water-log/admin.ts @@ -1,48 +1,67 @@ +/** + * Water Log — admin-facing server actions. + * + * Two access paths: + * 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log` + * and runs brand-scoped via `withBrand()`. + * 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate + * `water_admin_sessions` cookie; `requireWaterAdminSession()` is the + * gate for those. + * + * All actions return either `{ success: true, ... }` or + * `{ success: false, error }`. We never throw across the action boundary + * for expected errors (duplicate name, invalid input) — those are user + * feedback. Unexpected errors (DB outage, programmer error) propagate + * and surface in the toast as a generic message. + */ "use server"; +import { cookies } from "next/headers"; +import { and, desc, eq, gte, lte, sql, SQL } from "drizzle-orm"; import { getAdminUser } from "@/lib/admin-permissions"; +import { withBrand } from "@/db/client"; +import { + waterHeadgates, + waterIrrigators, + waterLogEntries, + waterAdminSessions, + type WaterHeadgate, + type WaterIrrigator, + type WaterLogEntry, +} from "@/db/schema/water-log"; +import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin"; +import { logAuditEvent, logAlert } from "@/lib/water-log-audit"; -// TODO(migration): the water-log feature was built on Supabase RPCs -// (`create_water_headgate`, `update_water_headgate`, `delete_water_headgate`, -// `get_water_users`, `create_water_user`, `update_water_user`, -// `delete_water_user`, `reset_water_user_pin`, `get_water_entries`, -// `get_water_headgates_admin`, `regenerate_headgate_token`, -// `update_water_entry`, `delete_water_entry`, `get_water_entry_by_id`, -// `get_water_display_summary`, `get_water_alert_log`) and tables that -// are not part of the SaaS rebuild's `db/schema/` (`water_headgates`, -// `water_users`, `water_entries`, `water_sessions`, -// `water_admin_sessions`, `water_admin_settings`, `water_alert_log`). -// All actions below preserve their original signature and return -// empty / no-op responses so the admin UI degrades gracefully. To -// re-enable water log, add the tables to `db/schema/` and -// re-implement these against Drizzle. Same pattern as -// `actions/route-trace/lots.ts`. +// ── Types used by both server and client ──────────────────────────────────── -type Irrigator = { +export type AdminHeadgate = { + id: string; + name: string; + status: "open" | "closed" | "maintenance"; + unit: string; + active: boolean; + headgate_token: string; + last_used_at: string | null; + high_threshold: number | null; + low_threshold: number | null; + max_flow_gpm: number | null; + notes: string | null; + created_at: string; +}; + +export type AdminIrrigator = { id: string; name: string; role: "irrigator" | "water_admin"; active: boolean; language_preference: string; + phone: string | null; + notes: string | null; last_used_at: string | null; created_at: string; - deleted_at?: string | null; }; -type Headgate = { - id: string; - name: string; - active: boolean; - unit: string; - created_at: string; - deleted_at?: string | null; - headgate_token?: string | null; - last_used_at?: string | null; - high_threshold?: number | null; - low_threshold?: number | null; -}; - -type WaterEntry = { +export type AdminEntry = { id: string; headgate_id: string; user_id: string; @@ -50,162 +69,13 @@ type WaterEntry = { user_name: string; measurement: number; unit: string; + total_gallons: number | null; + method: "manual" | "meter" | "estimate" | "qr"; notes: string | null; submitted_via: string; + photo_url: string | null; logged_at: string; - headgate_unit?: string; -}; - -const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild"; - -// ── Headgate Admin ────────────────────────────────────────── - -export async function createWaterHeadgate( - _brandId: string, - _name: string, - _unit: string = "CFS" -): Promise<{ success: boolean; headgate?: Headgate; 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" }; - } - return { success: false, error: NOT_CONFIGURED }; -} - -export async function updateWaterHeadgate( - _headgateId: string, - _name: string, - _active: boolean, - _unit?: string, - _highThreshold?: number | null, - _lowThreshold?: number | null -): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -// ── Irrigator Admin ───────────────────────────────────────── - -export async function getWaterIrrigators(_brandId: string): Promise { - return []; -} - -export async function createWaterIrrigator( - _brandId: string, - _name: string, - _lang: string = "en" -): Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }> { - return createWaterUser(_brandId, _name, "irrigator", _lang) as Promise<{ - success: boolean; - irrigator?: Irrigator; - pin?: string; - error?: string; - }>; -} - -export async function createWaterUser( - _brandId: string, - _name: string, - _role: "irrigator" | "water_admin", - _lang: string = "en" -): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> { - return { success: false, error: NOT_CONFIGURED }; -} - -export async function updateWaterIrrigator( - _irrigatorId: string, - _name: string, - _active: boolean, - _lang: string, - _role: "irrigator" | "water_admin" -): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -export async function resetWaterIrrigatorPin( - _irrigatorId: 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) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -export async function deleteWaterUser(_userId: string): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -export async function deleteWaterHeadgate(_headgateId: string): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -// ── Entries ──────────────────────────────────────────────── - -export async function getWaterEntries(_brandId: string, _limit = 50): Promise { - return []; -} - -export async function getWaterHeadgatesAdmin(_brandId: string): Promise { - return []; -} - -export async function regenerateHeadgateToken( - _headgateId: string -): Promise<{ success: boolean; token?: string; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -// ── Entry edit/delete ───────────────────────────────────── - -export async function updateWaterEntry( - _entryId: string, - _measurement: number, - _notes: string | null, - _unit?: string -): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -export async function deleteWaterEntry(_entryId: string): Promise<{ success: boolean; error?: string }> { - const adminUser = await getAdminUser(); - if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; -} - -export async function getWaterEntryById(_entryId: string): Promise { - return null; -} - -// ── Display summary ─────────────────────────────────── - -export type WaterDisplayEntry = { - logged_at: string; - headgate_name: string; - user_name: string; - user_role: string; - measurement: number; - unit: string; - notes: string | null; - submitted_via: string; + logged_date: string | null; }; export type WaterDisplayHeadgate = { @@ -221,13 +91,9 @@ export type WaterDisplaySummary = { headgates: WaterDisplayHeadgate[]; today_count: number; today_total: number; - recent_entries: WaterDisplayEntry[]; + recent_entries: AdminEntry[]; }; -export async function getWaterDisplaySummary(_brandId: string): Promise { - return null; -} - export type AlertLogEntry = { id: string; alert_type: "high" | "low"; @@ -240,6 +106,906 @@ export type AlertLogEntry = { formatted_time: string; }; -export async function getWaterAlertLog(_brandId: string, _limit = 50): Promise { - return []; +// ── Auth helpers ─────────────────────────────────────────────────────────── + +async function requireWaterAdminPermission() { + const adminUser = await getAdminUser(); + if (!adminUser) return { ok: false as const, error: "Not authenticated" }; + if ( + !adminUser.can_manage_water_log && + adminUser.role !== "platform_admin" + ) { + return { ok: false as const, error: "Not authorized" }; + } + return { ok: true as const, adminUser }; } + +/** + * For the `/water/admin` (PIN) portal. Returns the admin + brand if a + * valid session cookie is present. Falls back to a 401-shaped error. + */ +export async function requireWaterAdminSession(): Promise< + | { ok: true; adminUserId: string; brandId: string } + | { ok: false; error: string } +> { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("wl_admin_session")?.value; + if (!sessionId) return { ok: false, error: "Not signed in" }; + + return withBrand(globalThis.__TUXEDO_BRAND_ID__ ?? "", async () => { + // session lookup must use platform-admin to read across brands + const { withPlatformAdmin } = await import("@/db/client"); + return withPlatformAdmin(async (db) => { + const rows = await db + .select() + .from(waterAdminSessions) + .where(eq(waterAdminSessions.id, sessionId)) + .limit(1); + const row = rows[0]; + if (!row) return { ok: false as const, error: "Session not found" }; + if (row.expiresAt.getTime() < Date.now()) { + return { ok: false as const, error: "Session expired" }; + } + return { + ok: true as const, + adminUserId: row.adminUserId, + brandId: row.brandId, + }; + }); + }); +} + +// Global hint used by the field admin portal: water log is currently +// scoped to the Tuxedo brand only. Surfacing this in one place makes it +// easy to lift later when we add multi-tenant water log. +declare global { + // eslint-disable-next-line no-var + var __TUXEDO_BRAND_ID__: string | undefined; +} +globalThis.__TUXEDO_BRAND_ID__ ??= "64294306-5f42-463d-a5e8-2ad6c81a96de"; + +// ── Mappers (DB row → client shape) ──────────────────────────────────────── + +function mapHeadgate(h: WaterHeadgate): AdminHeadgate { + return { + id: h.id, + name: h.name, + status: (h.status as AdminHeadgate["status"]) ?? "open", + unit: h.unit ?? "CFS", + active: h.active, + headgate_token: h.headgateToken, + last_used_at: h.lastUsedAt?.toISOString() ?? null, + high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null, + low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null, + max_flow_gpm: h.maxFlowGpm != null ? Number(h.maxFlowGpm) : null, + notes: h.notes, + created_at: h.createdAt.toISOString(), + }; +} + +function mapIrrigator(i: WaterIrrigator): AdminIrrigator { + return { + id: i.id, + name: i.name, + role: (i.role as AdminIrrigator["role"]) ?? "irrigator", + active: i.active, + language_preference: i.languagePreference, + phone: i.phone, + notes: i.notes, + last_used_at: i.lastUsedAt?.toISOString() ?? null, + created_at: i.createdAt.toISOString(), + }; +} + +function mapEntry( + e: WaterLogEntry & { headgate_name: string; user_name: string }, +): AdminEntry { + return { + id: e.id, + headgate_id: e.headgateId, + user_id: e.irrigatorId, + headgate_name: e.headgate_name, + user_name: e.user_name, + measurement: Number(e.measurement), + unit: e.unit, + total_gallons: e.totalGallons != null ? Number(e.totalGallons) : null, + method: (e.method as AdminEntry["method"]) ?? "manual", + notes: e.notes, + submitted_via: e.submittedVia, + photo_url: e.photoUrl, + logged_at: e.loggedAt.toISOString(), + logged_date: e.loggedDate, + }; +} + +// ── Headgate CRUD ────────────────────────────────────────────────────────── + +export async function createWaterHeadgate( + brandId: string, + name: string, + unit: string = "CFS", + options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {}, +): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const trimmed = name?.trim(); + if (!trimmed) return { success: false, error: "Name is required" }; + if (trimmed.length > 80) return { success: false, error: "Name is too long" }; + + return withBrand(brandId, async (db) => { + const existing = await db + .select({ id: waterHeadgates.id }) + .from(waterHeadgates) + .where( + and(eq(waterHeadgates.brandId, brandId), eq(waterHeadgates.name, trimmed)), + ) + .limit(1); + if (existing[0]) { + return { success: false, error: "A headgate with that name already exists" }; + } + const [row] = await db + .insert(waterHeadgates) + .values({ + brandId, + name: trimmed, + unit, + highThreshold: options.highThreshold?.toString() ?? null, + lowThreshold: options.lowThreshold?.toString() ?? null, + notes: options.notes ?? null, + headgateToken: `hg_${cryptoRandomHex(12)}`, + }) + .returning(); + if (!row) return { success: false, error: "Insert failed" }; + await logAuditEvent({ + brandId, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "create", + entityType: "headgate", + entityId: row.id, + details: { name: row.name, unit: row.unit }, + }); + return { success: true, headgate: mapHeadgate(row) }; + }); +} + +export async function updateWaterHeadgate( + headgateId: string, + name: string, + active: boolean, + unit?: string, + highThreshold?: number | null, + lowThreshold?: number | null, + notes?: string | null, + status?: AdminHeadgate["status"], +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + + // Headgates aren't directly brand-scoped in the URL — look up brand first. + const brand = await withPlatformAdminBrandOf(headgateId); + if (!brand) return { success: false, error: "Headgate not found" }; + + return withBrand(brand, async (db) => { + const trimmed = name?.trim(); + if (!trimmed) return { success: false, error: "Name is required" }; + const existing = await db + .select({ id: waterHeadgates.id }) + .from(waterHeadgates) + .where( + and( + eq(waterHeadgates.brandId, brand), + eq(waterHeadgates.name, trimmed), + ), + ) + .limit(1); + if (existing[0] && existing[0].id !== headgateId) { + return { success: false, error: "Another headgate already uses that name" }; + } + const updated = await db + .update(waterHeadgates) + .set({ + name: trimmed, + active, + unit: unit ?? "CFS", + highThreshold: highThreshold?.toString() ?? null, + lowThreshold: lowThreshold?.toString() ?? null, + notes: notes ?? null, + status: status ?? "open", + }) + .where(eq(waterHeadgates.id, headgateId)) + .returning(); + if (!updated[0]) return { success: false, error: "Headgate not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "update", + entityType: "headgate", + entityId: headgateId, + details: { name: trimmed, active, status, unit, highThreshold, lowThreshold }, + }); + return { success: true }; + }); +} + +export async function deleteWaterHeadgate( + headgateId: string, +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOf(headgateId); + if (!brand) return { success: false, error: "Headgate not found" }; + return withBrand(brand, async (db) => { + const deleted = await db + .delete(waterHeadgates) + .where(eq(waterHeadgates.id, headgateId)) + .returning({ id: waterHeadgates.id }); + if (!deleted[0]) return { success: false, error: "Headgate not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "delete", + entityType: "headgate", + entityId: headgateId, + }); + return { success: true }; + }); +} + +export async function regenerateHeadgateToken( + headgateId: string, +): Promise<{ success: boolean; token?: string; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOf(headgateId); + if (!brand) return { success: false, error: "Headgate not found" }; + return withBrand(brand, async (db) => { + const token = `hg_${cryptoRandomHex(12)}`; + const updated = await db + .update(waterHeadgates) + .set({ headgateToken: token }) + .where(eq(waterHeadgates.id, headgateId)) + .returning({ token: waterHeadgates.headgateToken }); + if (!updated[0]) return { success: false, error: "Headgate not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "regenerate_token", + entityType: "headgate", + entityId: headgateId, + }); + return { success: true, token: updated[0].token }; + }); +} + +export async function getWaterHeadgatesAdmin( + brandId: string, +): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return []; + return withBrand(brandId, async (db) => { + const rows = await db + .select() + .from(waterHeadgates) + .where(eq(waterHeadgates.brandId, brandId)) + .orderBy(desc(waterHeadgates.createdAt)); + return rows.map(mapHeadgate); + }); +} + +// ── Irrigator (user) CRUD ────────────────────────────────────────────────── + +export async function getWaterIrrigators(brandId: string): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return []; + return withBrand(brandId, async (db) => { + const rows = await db + .select() + .from(waterIrrigators) + .where(eq(waterIrrigators.brandId, brandId)) + .orderBy(desc(waterIrrigators.createdAt)); + return rows.map(mapIrrigator); + }); +} + +export type CreateUserResult = { + success: boolean; + user?: AdminIrrigator; + pin?: string; + error?: string; +}; + +export async function createWaterUser( + brandId: string, + name: string, + role: "irrigator" | "water_admin" = "irrigator", + language: string = "en", + phone?: string | null, +): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const trimmed = name?.trim(); + if (!trimmed) return { success: false, error: "Name is required" }; + if (trimmed.length > 80) return { success: false, error: "Name is too long" }; + if (!["en", "es"].includes(language)) { + return { success: false, error: "Invalid language" }; + } + + const pin = generatePin(); + const pinHash = hashPin(pin); + + const result = await withBrand(brandId, async (db) => { + const existing = await db + .select({ id: waterIrrigators.id }) + .from(waterIrrigators) + .where( + and( + eq(waterIrrigators.brandId, brandId), + eq(waterIrrigators.name, trimmed), + ), + ) + .limit(1); + if (existing[0]) { + return { success: false as const, error: "A user with that name already exists" }; + } + const [row] = await db + .insert(waterIrrigators) + .values({ + brandId, + name: trimmed, + pinHash, + role, + languagePreference: language, + phone: phone ?? null, + }) + .returning(); + if (!row) return { success: false as const, error: "Insert failed" }; + return { success: true as const, user: row }; + }); + if (!result.success) return { success: false, error: result.error }; + + await logAuditEvent({ + brandId, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "create", + entityType: "user", + entityId: result.user.id, + details: { name: result.user.name, role }, + }); + return { success: true, user: mapIrrigator(result.user), pin }; +} + +export async function createWaterIrrigator( + brandId: string, + name: string, + language: string = "en", +): Promise { + return createWaterUser(brandId, name, "irrigator", language); +} + +export async function updateWaterIrrigator( + irrigatorId: string, + name: string, + active: boolean, + language: string, + role: "irrigator" | "water_admin", + phone?: string | null, + notes?: string | null, +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId); + if (!brand) return { success: false, error: "User not found" }; + return withBrand(brand, async (db) => { + const trimmed = name?.trim(); + if (!trimmed) return { success: false, error: "Name is required" }; + if (!["en", "es"].includes(language)) { + return { success: false, error: "Invalid language" }; + } + const updated = await db + .update(waterIrrigators) + .set({ + name: trimmed, + active, + languagePreference: language, + role, + phone: phone ?? null, + notes: notes ?? null, + }) + .where(eq(waterIrrigators.id, irrigatorId)) + .returning(); + if (!updated[0]) return { success: false, error: "User not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "update", + entityType: "user", + entityId: irrigatorId, + details: { name: trimmed, active, role, language }, + }); + return { success: true }; + }); +} + +export async function deleteWaterUser( + userId: string, +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOfIrrigator(userId); + if (!brand) return { success: false, error: "User not found" }; + return withBrand(brand, async (db) => { + const deleted = await db + .delete(waterIrrigators) + .where(eq(waterIrrigators.id, userId)) + .returning({ id: waterIrrigators.id }); + if (!deleted[0]) return { success: false, error: "User not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "delete", + entityType: "user", + entityId: userId, + }); + return { success: true }; + }); +} + +export async function resetWaterIrrigatorPin( + userId: string, +): Promise<{ success: boolean; pin?: string; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOfIrrigator(userId); + if (!brand) return { success: false, error: "User not found" }; + const pin = generatePin(); + const pinHash = hashPin(pin); + return withBrand(brand, async (db) => { + const updated = await db + .update(waterIrrigators) + .set({ pinHash }) + .where(eq(waterIrrigators.id, userId)) + .returning({ id: waterIrrigators.id }); + if (!updated[0]) return { success: false, error: "User not found" }; + // Invalidate existing sessions for safety + await db.execute( + sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`, + ); + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "reset_pin", + entityType: "user", + entityId: userId, + }); + return { success: true, pin }; + }); +} + +// ── Entry CRUD ───────────────────────────────────────────────────────────── + +export async function getWaterEntries( + brandId: string, + limit: number = 50, +): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return []; + const safeLimit = Math.min(Math.max(limit, 1), 1000); + return withBrand(brandId, async (db) => { + const rows = await db.execute<{ + id: string; + headgate_id: string; + irrigator_id: string; + headgate_name: string; + user_name: string; + measurement: string; + unit: string; + total_gallons: string | null; + method: string; + notes: string | null; + submitted_via: string; + photo_url: string | null; + logged_at: string; + logged_date: string | null; + }>(sql` + SELECT + e.id, e.headgate_id, e.irrigator_id, + h.name AS headgate_name, + i.name AS user_name, + e.measurement::text, e.unit, + e.total_gallons::text, e.method, e.notes, e.submitted_via, + e.photo_url, e.logged_at, e.logged_date + FROM water_log_entries e + JOIN water_headgates h ON h.id = e.headgate_id + JOIN water_irrigators i ON i.id = e.irrigator_id + WHERE e.brand_id = ${brandId} + ORDER BY e.logged_at DESC + LIMIT ${safeLimit} + `); + return rows.rows.map((r) => ({ + id: r.id, + headgate_id: r.headgate_id, + user_id: r.irrigator_id, + headgate_name: r.headgate_name, + user_name: r.user_name, + measurement: Number(r.measurement), + unit: r.unit, + total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, + method: (r.method ?? "manual") as AdminEntry["method"], + notes: r.notes, + submitted_via: r.submitted_via, + photo_url: r.photo_url, + logged_at: r.logged_at, + logged_date: r.logged_date, + })); + }); +} + +export async function getWaterEntryById(entryId: string): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return null; + const brand = await withPlatformAdminBrandOfEntry(entryId); + if (!brand) return null; + return withBrand(brand, async (db) => { + const rows = await db.execute<{ + id: string; + headgate_id: string; + irrigator_id: string; + headgate_name: string; + user_name: string; + measurement: string; + unit: string; + total_gallons: string | null; + method: string; + notes: string | null; + submitted_via: string; + photo_url: string | null; + logged_at: string; + logged_date: string | null; + }>(sql` + SELECT + e.id, e.headgate_id, e.irrigator_id, + h.name AS headgate_name, + i.name AS user_name, + e.measurement::text, e.unit, + e.total_gallons::text, e.method, e.notes, e.submitted_via, + e.photo_url, e.logged_at, e.logged_date + FROM water_log_entries e + JOIN water_headgates h ON h.id = e.headgate_id + JOIN water_irrigators i ON i.id = e.irrigator_id + WHERE e.id = ${entryId} + LIMIT 1 + `); + const r = rows.rows[0]; + if (!r) return null; + return { + id: r.id, + headgate_id: r.headgate_id, + user_id: r.irrigator_id, + headgate_name: r.headgate_name, + user_name: r.user_name, + measurement: Number(r.measurement), + unit: r.unit, + total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, + method: (r.method ?? "manual") as AdminEntry["method"], + notes: r.notes, + submitted_via: r.submitted_via, + photo_url: r.photo_url, + logged_at: r.logged_at, + logged_date: r.logged_date, + }; + }); +} + +export async function updateWaterEntry( + entryId: string, + measurement: number, + notes: string | null, + unit?: string, + method?: AdminEntry["method"], +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + if (!Number.isFinite(measurement) || measurement < 0) { + return { success: false, error: "Measurement must be a non-negative number" }; + } + const brand = await withPlatformAdminBrandOfEntry(entryId); + if (!brand) return { success: false, error: "Entry not found" }; + return withBrand(brand, async (db) => { + const updated = await db + .update(waterLogEntries) + .set({ + measurement: measurement.toString(), + unit: unit ?? "CFS", + method: method ?? "manual", + notes, + }) + .where(eq(waterLogEntries.id, entryId)) + .returning({ id: waterLogEntries.id }); + if (!updated[0]) return { success: false, error: "Entry not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "update", + entityType: "entry", + entityId: entryId, + details: { measurement, unit, method }, + }); + return { success: true }; + }); +} + +export async function deleteWaterEntry( + entryId: string, +): Promise<{ success: boolean; error?: string }> { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return { success: false, error: auth.error }; + const brand = await withPlatformAdminBrandOfEntry(entryId); + if (!brand) return { success: false, error: "Entry not found" }; + return withBrand(brand, async (db) => { + const deleted = await db + .delete(waterLogEntries) + .where(eq(waterLogEntries.id, entryId)) + .returning({ id: waterLogEntries.id }); + if (!deleted[0]) return { success: false, error: "Entry not found" }; + await logAuditEvent({ + brandId: brand, + actorId: auth.adminUser.user_id ?? null, + actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", + action: "delete", + entityType: "entry", + entityId: entryId, + }); + return { success: true }; + }); +} + +// ── Display summary (used by the field-admin dashboard) ──────────────────── + +export async function getWaterDisplaySummary( + brandId: string, +): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return null; + return withBrand(brandId, async (db) => { + // Headgates with their latest entry + const headgateRows = await db.execute<{ + id: string; + name: string; + unit: string; + last_logged_at: string | null; + latest_user: string | null; + latest_measurement: string | null; + }>(sql` + SELECT + h.id, h.name, h.unit, + ( + SELECT MAX(e.logged_at) + FROM water_log_entries e + WHERE e.headgate_id = h.id + ) AS last_logged_at, + ( + SELECT i.name + FROM water_log_entries e2 + JOIN water_irrigators i ON i.id = e2.irrigator_id + WHERE e2.headgate_id = h.id + ORDER BY e2.logged_at DESC + LIMIT 1 + ) AS latest_user, + ( + SELECT e3.measurement::text + FROM water_log_entries e3 + WHERE e3.headgate_id = h.id + ORDER BY e3.logged_at DESC + LIMIT 1 + ) AS latest_measurement + FROM water_headgates h + WHERE h.brand_id = ${brandId} + ORDER BY h.name ASC + `); + + const now = Date.now(); + const headgates: WaterDisplayHeadgate[] = headgateRows.rows.map((r) => { + const last = r.last_logged_at ? new Date(r.last_logged_at) : null; + const minutesAgo = last ? Math.floor((now - last.getTime()) / 60000) : null; + return { + id: r.id, + name: r.name, + unit: r.unit ?? "CFS", + latest_entry: + r.latest_measurement != null && r.latest_user && r.last_logged_at + ? { + measurement: Number(r.latest_measurement), + user_name: r.latest_user, + logged_at: r.last_logged_at, + } + : null, + last_logged_at: r.last_logged_at, + minutes_ago: minutesAgo, + }; + }); + + // Today's entries + const todayCount = await db.execute<{ count: string; total: string | null }>(sql` + SELECT + COUNT(*)::text AS count, + COALESCE(SUM(measurement), 0)::text AS total + FROM water_log_entries + WHERE brand_id = ${brandId} + AND logged_date = (NOW() AT TIME ZONE 'UTC')::date + `); + const today = todayCount.rows[0] ?? { count: "0", total: "0" }; + + // Recent entries (last 10) + const recent = await db.execute<{ + id: string; + headgate_id: string; + irrigator_id: string; + headgate_name: string; + user_name: string; + measurement: string; + unit: string; + total_gallons: string | null; + method: string; + notes: string | null; + submitted_via: string; + photo_url: string | null; + logged_at: string; + logged_date: string | null; + }>(sql` + SELECT + e.id, e.headgate_id, e.irrigator_id, + h.name AS headgate_name, + i.name AS user_name, + e.measurement::text, e.unit, + e.total_gallons::text, e.method, e.notes, e.submitted_via, + e.photo_url, e.logged_at, e.logged_date + FROM water_log_entries e + JOIN water_headgates h ON h.id = e.headgate_id + JOIN water_irrigators i ON i.id = e.irrigator_id + WHERE e.brand_id = ${brandId} + ORDER BY e.logged_at DESC + LIMIT 10 + `); + + return { + headgates, + today_count: parseInt(today.count, 10) || 0, + today_total: parseFloat(today.total ?? "0") || 0, + recent_entries: recent.rows.map((r) => ({ + id: r.id, + headgate_id: r.headgate_id, + user_id: r.irrigator_id, + headgate_name: r.headgate_name, + user_name: r.user_name, + measurement: Number(r.measurement), + unit: r.unit, + total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, + method: (r.method ?? "manual") as AdminEntry["method"], + notes: r.notes, + submitted_via: r.submitted_via, + photo_url: r.photo_url, + logged_at: r.logged_at, + logged_date: r.logged_date, + })), + }; + }); +} + +export async function getWaterAlertLog( + brandId: string, + limit: number = 50, +): Promise { + const auth = await requireWaterAdminPermission(); + if (!auth.ok) return []; + return withBrand(brandId, async (db) => { + const rows = await db.execute<{ + id: string; + alert_type: string; + threshold_value: string | null; + reading_value: string | null; + message_sent: string | null; + sent_at: string | null; + created_at: string; + headgate_name: string | null; + }>(sql` + SELECT + a.id, a.alert_type, + NULL::text AS threshold_value, + NULL::text AS reading_value, + a.message AS message_sent, + a.sent_at, + a.created_at, + h.name AS headgate_name + FROM water_alert_log a + LEFT JOIN water_headgates h ON h.id = a.headgate_id + WHERE a.brand_id = ${brandId} + ORDER BY a.created_at DESC + LIMIT ${Math.min(Math.max(limit, 1), 500)} + `); + return rows.rows.map((r) => ({ + id: r.id, + alert_type: r.alert_type === "low" ? "low" : "high", + threshold_value: 0, + reading_value: 0, + message_sent: r.message_sent, + sent_at: r.sent_at ?? r.created_at, + created_at: r.created_at, + headgate_name: r.headgate_name ?? "Unknown", + formatted_time: new Date(r.created_at).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }), + })); + }); +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Find the brand_id of a headgate by id. Uses `withPlatformAdmin` because + * we don't know which brand to scope the lookup to yet. + */ +async function withPlatformAdminBrandOf(headgateId: string): Promise { + const { withPlatformAdmin } = await import("@/db/client"); + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ brandId: waterHeadgates.brandId }) + .from(waterHeadgates) + .where(eq(waterHeadgates.id, headgateId)) + .limit(1); + return rows[0]?.brandId ?? null; + }); +} + +async function withPlatformAdminBrandOfIrrigator(userId: string): Promise { + const { withPlatformAdmin } = await import("@/db/client"); + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ brandId: waterIrrigators.brandId }) + .from(waterIrrigators) + .where(eq(waterIrrigators.id, userId)) + .limit(1); + return rows[0]?.brandId ?? null; + }); +} + +async function withPlatformAdminBrandOfEntry(entryId: string): Promise { + const { withPlatformAdmin } = await import("@/db/client"); + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ brandId: waterLogEntries.brandId }) + .from(waterLogEntries) + .where(eq(waterLogEntries.id, entryId)) + .limit(1); + return rows[0]?.brandId ?? null; + }); +} + +function cryptoRandomHex(bytes: number): string { + const arr = new Uint8Array(bytes); + // Use globalThis.crypto when available (Node 19+, all browsers). + // Fall back to require("crypto") for older runtimes. + if (typeof globalThis.crypto?.getRandomValues === "function") { + globalThis.crypto.getRandomValues(arr); + } else { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodeCrypto = require("node:crypto") as typeof import("node:crypto"); + nodeCrypto.randomFillSync(arr); + } + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +// Re-export for the field action to do an admin-PIN check. +export { verifyPin }; +export { logAlert }; diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index dca6911..ea59686 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -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"; 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 -// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`, -// `submit_water_entry`, `trigger_water_alert`, -// `get_water_admin_session`) and tables (`water_headgates`, -// `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. +// Field sessions last 8h — a working day in the field. Long enough +// that a single sign-in covers a morning shift + afternoon shift. +const FIELD_SESSION_HOURS = 8; +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; -type VerifyPinResult = { - success: true; - user_id: string; - name: string; - role: string; - session_id: string; - lang: string; -} | { - success: false; - error: string; -}; +// ── Types ────────────────────────────────────────────────────────────────── -type SubmitEntryResult = { - success: true; - entry_id: string; -} | { - success: false; - error: string; -}; - -type Headgate = { +type FieldHeadgate = { id: string; name: string; + unit: string; + status: string; + high_threshold: number | null; + low_threshold: number | null; 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( _brandId: string, - _activeOnly = false -): Promise { - return []; + activeOnly: boolean = false, +): Promise { + 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( _brandId: string, - _pin: string + pin: string, ): Promise { - 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( - _headgateId: string, - _measurement: number, - _unit: string, - _notes: string, - _photoUrl?: string, - _latitude?: number, - _longitude?: number, - _headgateLocked?: boolean -): Promise { - const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_session")?.value; + // Look the irrigator up across all brands (PIN is the only credential). + const lookup = await withPlatformAdmin(async (db) => { + const rows = await db + .select() + .from(waterIrrigators) + .where(eq(waterIrrigators.active, true)) + .orderBy(waterIrrigators.name); + return rows; + }); - if (!sessionId) { - return { success: false, error: "Not logged in" }; + // Constant-time-ish: check every row, only succeed on the first match. + // (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 { + // ── 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 { + 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 { 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"); } export async function logoutWaterAdmin(): Promise { 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"); } @@ -91,7 +377,49 @@ export async function getWaterSession(): Promise { 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 { + if (!["en", "es"].includes(lang)) return; const cookieStore = await cookies(); cookieStore.set("wl_lang", lang, { httpOnly: false, @@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise { }); } -export async function getWaterAdminSession(): Promise<{ - user_id: string; - name: string; - role: string; -} | null> { - const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - - if (!sessionId) return null; +// ── Internal helpers ────────────────────────────────────────────────────── +/** + * 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; } + +function pad(n: number): string { + return n.toString().padStart(2, "0"); +} diff --git a/src/actions/water-log/settings.ts b/src/actions/water-log/settings.ts index 6df5d07..1fe3d1a 100644 --- a/src/actions/water-log/settings.ts +++ b/src/actions/water-log/settings.ts @@ -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"; +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 { 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`, -// `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 = { +export type AdminSettings = { enabled: boolean; - session_duration_hours: number; - can_edit_entries: boolean; - can_delete_entries: boolean; - can_export_csv: boolean; - alert_phone?: string | null; - alerts_enabled?: boolean; + sessionDurationHours: number; + canEditEntries: boolean; + canDeleteEntries: boolean; + canExportCsv: boolean; + alertPhone: string | null; + 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 { - return null; +export async function getWaterAdminSettings( + brandId: string, +): Promise { + 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( - _brandId: string, - _settings: Partial -): Promise<{ success: boolean; error?: string }> { + brandId: string, + settings: Partial, +): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" }; - return { success: false, error: NOT_CONFIGURED }; + if ( + !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 1–168 hours" }; + } + + return withBrand(brandId, async (db) => { + const update: Partial = {}; + 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, - _pin: string -): Promise<{ success: boolean; session_id?: string; error?: string }> { - return { success: false, error: NOT_CONFIGURED }; +/** + * Generate a fresh PIN, hash it, and save it on the brand. Returns the + * plaintext PIN *once* — caller is responsible for showing it to the + * admin and never persisting it. + */ +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 }; + }); } diff --git a/src/app/admin/water-log/settings/page.tsx b/src/app/admin/water-log/settings/page.tsx index 2e0c65b..9b9820e 100644 --- a/src/app/admin/water-log/settings/page.tsx +++ b/src/app/admin/water-log/settings/page.tsx @@ -1,25 +1,43 @@ "use client"; +/** + * /admin/water-log/settings + * + * Site-admin-facing settings page for the Water Log module. Controls: + * - Whether `/water/admin` is enabled + * - 4-digit admin PIN (regenerate on demand) + * - Session duration (hours) + * - Per-coarse-permission flags (edit / delete / export) + * - High/low alert phone + enable flag + * + * Visual language: Field Almanac. Same cream + forest palette as + * the main WaterLogAdminPanel, but trimmed to a single column for + * a focused "form" feel. + */ + import { useState, useEffect, useCallback } from "react"; -import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings"; -import { useRouter } from "next/navigation"; +import { + getWaterAdminSettings, + saveWaterAdminSettings, + regenerateAdminPin, + type AdminSettings, +} from "@/actions/water-log/settings"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; export default function WaterLogSettingsPage() { - const router = useRouter(); - const [settings, setSettings] = useState(null); + const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [regenerating, setRegenerating] = useState(false); + const [revealedPin, setRevealedPin] = useState(null); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); // Form state const [enabled, setEnabled] = useState(false); - const [pin, setPin] = useState(""); - const [confirmPin, setConfirmPin] = useState(""); const [sessionDuration, setSessionDuration] = useState(12); const [canEdit, setCanEdit] = useState(true); - const [canDelete, setCanDelete] = useState(false); + const [canDelete, setCanDelete] = useState(true); const [canExport, setCanExport] = useState(true); const [alertPhone, setAlertPhone] = useState(""); const [alertsEnabled, setAlertsEnabled] = useState(false); @@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() { if (data) { setSettings(data); setEnabled(data.enabled); - setSessionDuration(data.session_duration_hours); - setCanEdit(data.can_edit_entries); - setCanDelete(data.can_delete_entries); - setCanExport(data.can_export_csv); - setAlertPhone(data.alert_phone ?? ""); - setAlertsEnabled(data.alerts_enabled ?? false); + setSessionDuration(data.sessionDurationHours); + setCanEdit(data.canEditEntries); + setCanDelete(data.canDeleteEntries); + setCanExport(data.canExportCsv); + setAlertPhone(data.alertPhone ?? ""); + setAlertsEnabled(data.alertsEnabled); } setLoading(false); }, []); useEffect(() => { - const init = async () => { - await loadSettings(); - }; - init(); + // Load on mount — setState-in-effect is intentional here + // because we're hydrating from server data. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadSettings(); }, [loadSettings]); async function handleSave(e: React.FormEvent) { e.preventDefault(); - if (enabled && pin && pin !== confirmPin) { - setMessage({ type: "error", text: "PINs do not match" }); - return; - } - if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) { - setMessage({ type: "error", text: "PIN must be exactly 4 digits" }); - return; - } setSaving(true); setMessage(null); const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, { enabled, - pin: pin || undefined, - session_duration_hours: sessionDuration, - can_edit_entries: canEdit, - can_delete_entries: canDelete, - can_export_csv: canExport, - alert_phone: alertPhone || null, - alerts_enabled: alertsEnabled, + sessionDurationHours: sessionDuration, + canEditEntries: canEdit, + canDeleteEntries: canDelete, + canExportCsv: canExport, + alertPhone: alertPhone || null, + alertsEnabled, }); if (result.success) { setMessage({ type: "success", text: "Settings saved" }); - setPin(""); - setConfirmPin(""); + if (result.settings) setSettings(result.settings); } else { setMessage({ type: "error", text: result.error ?? "Save failed" }); } setSaving(false); } + async function handleRegenerate() { + if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) { + return; + } + setRegenerating(true); + setMessage(null); + const result = await regenerateAdminPin(TUXEDO_BRAND_ID); + if (result.success && result.pin) { + setRevealedPin(result.pin); + setMessage({ + type: "success", + text: "New PIN generated. Save it now — it will not be shown again.", + }); + } else { + setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" }); + } + setRegenerating(false); + } + if (loading) { return ( -
- Loading... +
+ Loading…
); } return ( -
+
{/* Header */} -
-
-
- -
-

Water Log — Admin Portal

-

Configure PIN access for the field admin portal at /water/admin

-
-
+
+
+

§ 04 — Settings

+

+ Water Log · Admin Portal +

+

+ Configure PIN access for the field admin portal at /water/admin. + This is separate from the platform admin login. +

-
+
- {message && ( -
+
{message.text}
)} {/* Enable toggle */} -
-
-
-

Enable Admin Portal

-

Allow PIN-based access to /water/admin (separate from platform login)

-
- -
-
+ + + {enabled && ( <> {/* PIN */} -
-

4-Digit PIN

-

Leave blank to keep existing PIN. Set a new PIN to replace it.

-
-
- - 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" }} - /> -
-
- - 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" }} - /> -
-
-
- - {/* Session Duration */} -
-

Session Duration

-

How long the admin session lasts after login (1–72 hours).

-
- setSessionDuration(parseInt(e.target.value))} - className="flex-1" - /> - {sessionDuration}h -
-
- - {/* Permissions */} -
-

Permissions

- {[ - { 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 }) => ( -
-
-

{label}

-

{desc}

+ + {revealedPin ? ( +
+

+ New PIN — write it down +

+
+ + {revealedPin} +
- ))} -
+ ) : ( +
+

+ {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)."} +

+ +
+ )} + + + {/* Session Duration */} + +
+ setSessionDuration(parseInt(e.target.value, 10))} + className="flex-1 accent-[#1a4d2e]" + aria-label="Session duration in hours" + /> + + {sessionDuration}h + +
+
+ + {/* Permissions */} + +
+ + + +
+
{/* High/Low Alerts */} -
-

High/Low Alerts

-

Receive an SMS when a reading exceeds a headgate's thresholds.

- -
-
-

Enable Alerts

-

SMS on threshold breach

-
- -
- + + {alertsEnabled && ( -
- +
+ setAlertPhone(e.target.value)} - placeholder="+1234567890" - className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900" + placeholder="+13035551234" + className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]" autoComplete="tel" /> -

U.S. format recommended. Must include country code (e.g. +1 for USA).

+

+ U.S. format recommended. Include country code (e.g. +1 for USA). +

)} -
- - {/* QR hint */} -
-

- QR Lock: Irrigators can lock a headgate by visiting /water?h={'{headgate_token}'}. -

-
+
)}
); -} \ No newline at end of file +} + +function Card({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} +
+ ); +} + +function ToggleRow({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+

{label}

+ {description &&

{description}

} +
+ +
+ ); +} diff --git a/src/app/api/water-admin-auth/route.ts b/src/app/api/water-admin-auth/route.ts index f580e88..e7afbe2 100644 --- a/src/app/api/water-admin-auth/route.ts +++ b/src/app/api/water-admin-auth/route.ts @@ -1,52 +1,46 @@ +/** + * POST /api/water-admin-auth + * + * Body: { brandId: string, pin: string } + * Side effect: sets the `wl_admin_session` cookie on success. + * + * Used by the mobile/PIN-only `/water/admin/login` portal. The session + * itself is created by `verifyWaterAdminPin()` in + * `src/actions/water-log/settings.ts`, which is the single source of + * truth for admin PIN verification. + */ import { NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { pool } from "@/lib/db"; +import { verifyWaterAdminPin } from "@/actions/water-log/settings"; +import { captureError } from "@/lib/sentry"; export async function POST(request: Request) { try { - const { brandId, pin } = await request.json(); + const { brandId, pin } = (await request.json()) as { + brandId?: string; + pin?: string; + }; if (!brandId || !pin) { - return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 }); + return NextResponse.json( + { success: false, error: "Missing brandId or pin" }, + { status: 400 }, + ); } - - // Get admin settings - const settingsRes = await pool.query<{ - get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null; - }>( - `SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`, - [brandId], - ); - const settings = settingsRes.rows[0]?.get_water_admin_settings; - if (!settings?.enabled) { - return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 }); + const result = await verifyWaterAdminPin(brandId, pin); + if (!result.success) { + // Don't leak whether the PIN was wrong vs. not configured — both + // are the same to a field attacker probing the endpoint. + const status = result.error === "Invalid PIN" ? 401 : 403; + return NextResponse.json( + { success: false, error: "Invalid PIN" }, + { status }, + ); } - - // Verify PIN - const verifyRes = await pool.query<{ - verify_water_admin_pin: { success: boolean; session_id?: string } | null; - }>( - `SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`, - [brandId, pin], - ); - const verifyData = verifyRes.rows[0]?.verify_water_admin_pin; - if (!verifyData?.success || !verifyData.session_id) { - return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 }); - } - - // Create session cookie - const sessionId = verifyData.session_id; - const cookieStore = await cookies(); - const durationHours = settings.session_duration_hours ?? 4; - cookieStore.set("wl_admin_session", sessionId, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: durationHours * 3600, - path: "/", - }); - return NextResponse.json({ success: true }); - } catch { - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); + } catch (err) { + captureError(err as Error, { path: "/api/water-admin-auth" }); + return NextResponse.json( + { success: false, error: "Server error" }, + { status: 500 }, + ); } } diff --git a/src/app/api/water-logs/export/route.ts b/src/app/api/water-logs/export/route.ts index dfced66..4ed2da5 100644 --- a/src/app/api/water-logs/export/route.ts +++ b/src/app/api/water-logs/export/route.ts @@ -1,60 +1,86 @@ +/** + * GET /api/water-logs/export + * + * Streams the water log as JSON or CSV. Admin-only — checked via + * `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin + * session, with Tuxedo fallback for backward compat. + * + * GET /api/water-logs/export?format=csv + */ import { NextRequest, NextResponse } from "next/server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { pool } from "@/lib/db"; +import { getWaterEntries } from "@/actions/water-log/admin"; +import type { WaterLogReportRow } from "@/lib/water-log-reporting"; + +const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; export async function GET(request: NextRequest) { const adminUser = await getAdminUser(); if (!adminUser) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!adminUser.can_manage_water_log) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { searchParams } = new URL(request.url); const format = searchParams.get("format") ?? "json"; + const brandId = + adminUser.brand_id ?? + process.env.TUXEDO_BRAND_ID ?? + TUXEDO_BRAND_ID; - // Use brand_id from session (always Tuxedo for water log) or fallback to env - const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"; - - type WaterEntry = { - id: string; - user_id: string | null; - headgate_id: string | null; - measurement: number | null; - unit: string | null; - notes: string | null; - created_at: string; - }; - - const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>( - `SELECT get_water_entries($1, $2) AS "get_water_entries"`, - [brandId, 10000], - ); - const entries = data[0]?.get_water_entries ?? []; + const raw = await getWaterEntries(brandId, 10000); + const rows: WaterLogReportRow[] = raw.map((r) => ({ + logged_at: r.logged_at, + headgate_name: r.headgate_name, + user_name: r.user_name, + user_role: "irrigator", + measurement: r.measurement, + unit: r.unit, + notes: r.notes, + submitted_via: r.submitted_via, + })); if (format === "csv") { - const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"]; - const csvRows = [headers.join(",")]; - for (const row of entries) { - csvRows.push([ - row.id, - row.user_id ?? "", - row.headgate_id ?? "", - row.measurement ?? "", - row.unit ?? "", - `"${(row.notes ?? "").replace(/"/g, '""')}"`, - row.created_at ?? "", - ].join(",")); + const headers = [ + "When", + "Headgate", + "User", + "Measurement", + "Unit", + "Total Gallons", + "Method", + "Notes", + "Via", + "Photo URL", + ]; + const esc = (s: string | null | undefined) => + `"${(s ?? "").replace(/"/g, '""')}"`; + const lines: string[] = [headers.join(",")]; + for (const e of raw) { + lines.push( + [ + esc(e.logged_at), + esc(e.headgate_name), + esc(e.user_name), + String(e.measurement), + esc(e.unit), + e.total_gallons != null ? String(e.total_gallons) : "", + esc(e.method), + esc(e.notes), + esc(e.submitted_via), + esc(e.photo_url), + ].join(","), + ); } - return new NextResponse(csvRows.join("\n"), { + return new NextResponse(lines.join("\n"), { headers: { - "Content-Type": "text/csv", + "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`, }, }); } - return NextResponse.json(entries); + return NextResponse.json({ rows }); } diff --git a/src/components/admin/WaterLogAdminPanel.tsx b/src/components/admin/WaterLogAdminPanel.tsx index be1f3a2..ccb858a 100644 --- a/src/components/admin/WaterLogAdminPanel.tsx +++ b/src/components/admin/WaterLogAdminPanel.tsx @@ -1,64 +1,70 @@ "use client"; -import { useState } from "react"; +/** + * WaterLogAdminPanel — the main Water Log admin dashboard. + * + * Visual language: "Field Almanac." A rugged, light-themed almanac of + * the day's water use. The aesthetic borrows from USDA agricultural + * bulletins and old USGS survey markers — serif display type (Fraunces) + * for titles, mono (Fragment Mono) for measurements, and a single + * custom SVG (WaterGauge) that becomes the visual signature of the + * whole feature. + * + * - No purple-gradient SaaS look. + * - No glassmorphism. Strong borders, not soft shadows. + * - Field-notebook tone: precise, agricultural, slightly utilitarian. + * - Cream + forest + clay palette pulled from the project's existing + * `forest` / `sage` / `gold` tokens so the panel sits naturally + * inside the rest of /admin. + * + * The component is "use client" because it owns the filters and the + * entry-adding modals. All DB access happens through server actions in + * `src/actions/water-log/admin.ts`. + */ + +import { useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useCallback } from "react"; import { createWaterHeadgate, createWaterUser, + deleteWaterHeadgate, + deleteWaterUser, getWaterEntries, + regenerateHeadgateToken, + resetWaterIrrigatorPin, + type AdminEntry, + type AdminHeadgate, + type AdminIrrigator, } from "@/actions/water-log/admin"; -import { downloadWaterLogCSV, filterWaterLogEntries, shapeWaterLogEntry, formatDailyWaterReport, isInIrrigationSeason, type WaterLogFilter, type WaterLogReportRow, type IrrigationSeasonSettings } from "@/lib/water-log-reporting"; +import { + downloadWaterLogCSV, + filterWaterLogEntries, + formatDailyWaterReport, + isInIrrigationSeason, + shapeWaterLogEntry, + type IrrigationSeasonSettings, + type WaterLogFilter, + type WaterLogReportRow, +} from "@/lib/water-log-reporting"; +import { WaterGauge, SeasonMark } from "@/components/water/icons"; import { AdminButton } from "./design-system"; +import { formatDate, formatDateTime } from "@/lib/format-date"; -type WaterUser = { - id: string; - name: string; - role: "irrigator" | "water_admin"; - active: boolean; - language_preference: string; - last_used_at: string | null; - created_at: string; -}; - -type Headgate = { - id: string; - name: string; - active: boolean; - unit: string; - created_at: string; -}; - -type WaterEntry = { - id: string; - headgate_id: string; - user_id: string; - headgate_name: string; - user_name: string; - measurement: number; - unit: string; - notes: string | null; - submitted_via: string; - logged_at: string; -}; - -type WaterLogAdminPanelProps = { - initialUsers: WaterUser[]; - initialHeadgates: Headgate[]; - initialEntries: WaterEntry[]; +type Props = { + initialUsers: AdminIrrigator[]; + initialHeadgates: AdminHeadgate[]; + initialEntries: AdminEntry[]; brandId: string; canManage: boolean; }; -function formatDateTime(iso: string | null): string { - if (!iso) return "—"; - return new Date(iso).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} +const DEFAULT_SEASON: IrrigationSeasonSettings = { + seasonStartMonth: 3, + seasonStartDay: 15, + seasonEndMonth: 10, + seasonEndDay: 15, +}; export default function WaterLogAdminPanel({ initialUsers, @@ -66,27 +72,32 @@ export default function WaterLogAdminPanel({ initialEntries, brandId, canManage, -}: WaterLogAdminPanelProps) { +}: Props) { const router = useRouter(); - const [users] = useState(initialUsers); - const [headgates, setHeadgates] = useState(initialHeadgates); - const [entries] = useState(initialEntries); + const [headgates, setHeadgates] = useState(initialHeadgates); + const [users, setUsers] = useState(initialUsers); + const [entries] = useState(initialEntries); + const [toast, setToast] = useState<{ msg: string; kind: "ok" | "err" } | null>(null); - // ── Headgate add state ──────────────────────────── + // ── Forms ────────────────────────────────────────────────────────── const [showAddHg, setShowAddHg] = useState(false); - const [newHgName, setNewHgName] = useState(""); - const [newHgUnit, setNewHgUnit] = useState("CFS"); + const [newHg, setNewHg] = useState({ name: "", unit: "CFS", notes: "" }); const [savingHg, setSavingHg] = useState(false); - // ── User add state ───────────────────────────── const [showAddUser, setShowAddUser] = useState(false); - const [newUserName, setNewUserName] = useState(""); - const [newUserRole, setNewUserRole] = useState<"irrigator" | "water_admin">("irrigator"); - const [newUserLang, setNewUserLang] = useState("en"); + const [newUser, setNewUser] = useState({ + name: "", + role: "irrigator" as "irrigator" | "water_admin", + lang: "en", + phone: "", + }); const [savingUser, setSavingUser] = useState(false); const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null); + const [resetPin, setResetPin] = useState<{ name: string; pin: string } | null>( + null, + ); - // ── CSV filter state ───────────────────────────── + // ── Filters ──────────────────────────────────────────────────────── const [filterDateFrom, setFilterDateFrom] = useState(""); const [filterDateTo, setFilterDateTo] = useState(""); const [filterHeadgate, setFilterHeadgate] = useState(""); @@ -94,109 +105,181 @@ export default function WaterLogAdminPanel({ const [filterVia, setFilterVia] = useState(""); const [csvLoading, setCsvLoading] = useState(false); - // ── Report / messaging settings (localStorage-persisted) ── + // ── Season + report settings (localStorage) ─────────────────────── const [showReportSettings, setShowReportSettings] = useState(false); - const [messagingEnabled, setMessagingEnabled] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("wl_messaging_enabled") === "true"; - }); const [seasonStart, setSeasonStart] = useState(() => { - if (typeof window === "undefined") return { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 }; - const saved = localStorage.getItem("wl_season_settings"); - return saved ? JSON.parse(saved) : { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 }; - }); - const [sendEvenIfEmpty, setSendEvenIfEmpty] = useState(() => { - if (typeof window === "undefined") return false; - return localStorage.getItem("wl_send_even_if_empty") === "true"; + if (typeof window === "undefined") return DEFAULT_SEASON; + try { + const saved = localStorage.getItem("wl_season_settings"); + return saved ? { ...DEFAULT_SEASON, ...JSON.parse(saved) } : DEFAULT_SEASON; + } catch { + return DEFAULT_SEASON; + } }); const [recipientName, setRecipientName] = useState(() => - typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : "" - ); - const [recipientPhone, setRecipientPhone] = useState(() => - typeof window !== "undefined" ? localStorage.getItem("wl_recipient_phone") ?? "" : "" + typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : "", ); - // Persist to localStorage on change - function persistMessaging(val: boolean) { - setMessagingEnabled(val); - localStorage.setItem("wl_messaging_enabled", String(val)); - } - function persistSendEvenIfEmpty(val: boolean) { - setSendEvenIfEmpty(val); - localStorage.setItem("wl_send_even_if_empty", String(val)); - } - function persistSeason(s: IrrigationSeasonSettings) { - setSeasonStart(s); - localStorage.setItem("wl_season_settings", JSON.stringify(s)); - } - function persistRecipientName(v: string) { - setRecipientName(v); - localStorage.setItem("wl_recipient_name", v); - } - function persistRecipientPhone(v: string) { - setRecipientPhone(v); - localStorage.setItem("wl_recipient_phone", v); - } + // ── Derived data ────────────────────────────────────────────────── + const inSeason = useMemo( + () => isInIrrigationSeason(new Date(), seasonStart), + [seasonStart], + ); - // ── Preview report ───────────────────────────── - async function handlePreviewReport() { - const allEntries = await getWaterEntries(brandId, 500); - const today = new Date().toISOString().split("T")[0]; - const todayRows: WaterLogReportRow[] = allEntries - .filter((e) => e.logged_at.startsWith(today)) - .map(shapeWaterLogEntry); + const today = new Date().toISOString().slice(0, 10); + const todayEntries = useMemo( + () => entries.filter((e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today), + [entries, today], + ); + const todayTotal = todayEntries.reduce((s, e) => s + e.measurement, 0); - const inSeason = isInIrrigationSeason(new Date(), seasonStart); - const report = formatDailyWaterReport(todayRows, { - recipientName: recipientName || undefined, - sendEvenIfEmpty, - previewMode: true, - }); - - if (!report) { - alert(`[Preview] No entries today — report would be skipped (outside season: ${inSeason ? "in" : "out"}).`); - } else { - alert(`[Daily Report Preview — ${inSeason ? "in season" : "out of season"}]\n\n${report}`); + const lastEntryByHeadgate = useMemo(() => { + const map = new Map(); + for (const e of entries) { + const existing = map.get(e.headgate_id); + if (!existing || e.logged_at > existing.logged_at) { + map.set(e.headgate_id, e); + } } + return map; + }, [entries]); + + const filteredEntries = useMemo(() => { + return entries.filter((e) => { + if (filterDateFrom && e.logged_at < filterDateFrom) return false; + if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false; + if (filterHeadgate && e.headgate_id !== filterHeadgate) return false; + if (filterUser && e.user_id !== filterUser) return false; + if (filterVia && e.submitted_via !== filterVia) return false; + return true; + }); + }, [entries, filterDateFrom, filterDateTo, filterHeadgate, filterUser, filterVia]); + + // ── Toast helper ────────────────────────────────────────────────── + function notify(msg: string, kind: "ok" | "err" = "ok") { + setToast({ msg, kind }); + setTimeout(() => setToast(null), 2800); } - // ── Headgate handlers ──────────────────────────── + // ── Handlers ────────────────────────────────────────────────────── async function handleAddHg(e: React.FormEvent) { e.preventDefault(); - if (!newHgName.trim()) return; + if (!newHg.name.trim()) return; setSavingHg(true); - const result = await createWaterHeadgate(brandId, newHgName.trim(), newHgUnit); - if (result.success && result.headgate) { - setHeadgates((prev) => [...prev, result.headgate!]); - setNewHgName(""); - setNewHgUnit("CFS"); + const res = await createWaterHeadgate(brandId, newHg.name.trim(), newHg.unit, { + notes: newHg.notes.trim() || null, + }); + if (res.success && res.headgate) { + setHeadgates((prev) => [res.headgate!, ...prev]); + setNewHg({ name: "", unit: "CFS", notes: "" }); setShowAddHg(false); + notify("Headgate added"); + } else { + notify(res.error ?? "Failed", "err"); } setSavingHg(false); } - // ── User handlers ───────────────────────────── + async function handleDeleteHg(h: AdminHeadgate) { + if (!window.confirm(`Delete "${h.name}"? Existing log entries will be preserved.`)) return; + const res = await deleteWaterHeadgate(h.id); + if (res.success) { + setHeadgates((prev) => prev.filter((x) => x.id !== h.id)); + notify("Headgate removed"); + } else { + notify(res.error ?? "Failed", "err"); + } + } + + async function handleRegenerateToken(h: AdminHeadgate) { + if ( + !window.confirm( + `Regenerate QR token for "${h.name}"? Existing printed QR codes will stop working.`, + ) + ) return; + const res = await regenerateHeadgateToken(h.id); + if (res.success && res.token) { + setHeadgates((prev) => + prev.map((x) => (x.id === h.id ? { ...x, headgate_token: res.token! } : x)), + ); + notify("Token regenerated"); + } else { + notify(res.error ?? "Failed", "err"); + } + } + async function handleAddUser(e: React.FormEvent) { e.preventDefault(); - if (!newUserName.trim()) return; + if (!newUser.name.trim()) return; setSavingUser(true); - const result = await createWaterUser(brandId, newUserName.trim(), newUserRole, newUserLang); - if (result.success && result.user) { - setNewPin({ name: result.user!.name, pin: result.pin! }); - setNewUserName(""); - setNewUserRole("irrigator"); - setNewUserLang("en"); + const res = await createWaterUser( + brandId, + newUser.name.trim(), + newUser.role, + newUser.lang, + newUser.phone.trim() || null, + ); + if (res.success && res.user && res.pin) { + setUsers((prev) => [res.user!, ...prev]); + setNewPin({ name: res.user.name, pin: res.pin }); + setNewUser({ name: "", role: "irrigator", lang: "en", phone: "" }); setShowAddUser(false); + notify("User created"); + } else { + notify(res.error ?? "Failed", "err"); } setSavingUser(false); } - // ── CSV export ────────────────────────────────── + async function handleResetPin(u: AdminIrrigator) { + if (!window.confirm(`Reset PIN for "${u.name}"? Their current PIN will stop working.`)) + return; + const res = await resetWaterIrrigatorPin(u.id); + if (res.success && res.pin) { + setResetPin({ name: u.name, pin: res.pin }); + notify("PIN reset"); + } else { + notify(res.error ?? "Failed", "err"); + } + } + + async function handleDeleteUser(u: AdminIrrigator) { + if (!window.confirm(`Deactivate "${u.name}"? Their existing log entries will be preserved.`)) + return; + const res = await deleteWaterUser(u.id); + if (res.success) { + setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, active: false } : x))); + notify("User deactivated"); + } else { + notify(res.error ?? "Failed", "err"); + } + } + + async function handlePreviewReport() { + const all = await getWaterEntries(brandId, 1000); + const todayRows: WaterLogReportRow[] = all + .filter( + (e) => (e.logged_date ?? e.logged_at.slice(0, 10)) === today, + ) + .map(shapeWaterLogEntry); + const report = formatDailyWaterReport(todayRows, { + recipientName: recipientName || undefined, + previewMode: true, + }); + if (!report) { + notify(`No entries today — report would be skipped (season: ${inSeason ? "in" : "out"}).`); + return; + } + // The browser's `alert` blocks; the modal preview is the better UX in + // a future iteration, but alert is dependency-free today. + window.alert(`Daily Report — ${inSeason ? "in season" : "out of season"}\n\n${report}`); + } + async function handleExportCSV() { setCsvLoading(true); try { - const allEntries = await getWaterEntries(brandId, 500); - const shaped: WaterLogReportRow[] = allEntries.map(shapeWaterLogEntry); + const all = await getWaterEntries(brandId, 5000); + const shaped: WaterLogReportRow[] = all.map(shapeWaterLogEntry); const filters: WaterLogFilter = { dateFrom: filterDateFrom || undefined, dateTo: filterDateTo || undefined, @@ -205,516 +288,1106 @@ export default function WaterLogAdminPanel({ submittedVia: filterVia || undefined, }; const filtered = filterWaterLogEntries(shaped, filters); - const date = new Date().toISOString().split("T")[0]; - downloadWaterLogCSV(`water-log-${date}.csv`, filtered); + downloadWaterLogCSV(`water-log-${today}.csv`, filtered); + notify(`Exported ${filtered.length} entries`); } finally { setCsvLoading(false); } } - // Derived filtered entries for display - const filteredEntries = entries.filter((e) => { - if (filterDateFrom && e.logged_at < filterDateFrom) return false; - if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false; - if (filterHeadgate && e.headgate_id !== filterHeadgate) return false; - if (filterUser && e.user_id !== filterUser) return false; - if (filterVia && e.submitted_via !== filterVia) return false; - return true; - }); + function persistSeason(s: IrrigationSeasonSettings) { + setSeasonStart(s); + try { + localStorage.setItem("wl_season_settings", JSON.stringify(s)); + } catch {} + } + // ── Render ──────────────────────────────────────────────────────── return ( -
- {/* Navigation Actions */} -
- router.push("/admin/water-log/headgates")}> - 📱 QR Codes - - router.push("/admin/water-log/settings")}> - ⚙️ Settings - +
+ {/* Top tabs */} +
+ + }> + QR Codes + + + + }> + Settings + + - - ↗️ Field Admin + }> + Field Admin ↗
- {/* ── Today's Summary + Report Settings ── */} -
- {/* Summary card */} - {(() => { - const today = new Date().toISOString().split("T")[0]; - const todayEntries = entries.filter((e) => e.logged_at.startsWith(today)); - const inSeason = isInIrrigationSeason(new Date(), seasonStart); - const totalMeasurement = todayEntries.reduce((s, e) => s + e.measurement, 0); - const lastEntry = todayEntries.length > 0 - ? todayEntries.reduce((a, b) => (a.logged_at > b.logged_at ? a : b)) - : null; + {/* ── Today's Summary ─────────────────────────────────────── */} +
+ {/* Subtle topographic pattern overlay */} +
- return ( -
-
-
-

Today's Summary

-

- {inSeason - ? In irrigation season - : Outside irrigation season - }  ·  {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"} -

-
-
- - Preview Report - - setShowReportSettings(!showReportSettings)} - > - Report Settings - -
-
- - {todayEntries.length > 0 ? ( -
-
-

Total measurement

-

{totalMeasurement.toFixed(2)}

-
-
-

Last entry

-

{lastEntry?.headgate_name}

-

{lastEntry ? formatDateTime(lastEntry.logged_at) : "—"}

-
-
-

Messaging

-

- {messagingEnabled ? "Enabled" : "Disabled"} -

-
-
- ) : ( -

No entries recorded today.

- )} +
+ {/* Hero gauge */} +
+
+ Tuxedo Ditch Co.
- ); - })()} - - {/* Report settings panel */} - {showReportSettings && ( -
-
-

Report / Messaging Settings

- + +
+ Vol. {todayTotal.toFixed(2)} {headgates[0]?.unit ?? "CFS"}
+
-
-

- Preview only. These settings are local to this browser. Live scheduled WhatsApp/SMS delivery requires a future database settings table and messaging integration. +

+
+

+ Today's Summary +

+

+ + {inSeason ? ( + In irrigation season + ) : ( + Outside irrigation season + )} + · + + {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"} + + · + + {formatDate(new Date())} +

-
-
- - -
-
- - -
-
- -
- - persistSeason({ ...seasonStart, seasonStartDay: parseInt(e.target.value) || 1 })} - className="w-16 rounded-lg border border-[var(--admin-border)] px-2 py-1.5 text-sm" - /> -
-
-
- -
- - persistSeason({ ...seasonStart, seasonEndDay: parseInt(e.target.value) || 1 })} - className="w-16 rounded-lg border border-[var(--admin-border)] px-2 py-1.5 text-sm" - /> -
-
-
- - persistRecipientName(e.target.value)} - placeholder="David" - className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm" - /> -
-
- - persistRecipientPhone(e.target.value)} - placeholder="+1..." - className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm" - /> -
+
+ + h.active).length.toString()} + unit={`of ${headgates.length}`} + /> + u.active).length.toString()} + unit={`of ${users.length}`} + /> +
+ +
+ } + onClick={handlePreviewReport} + > + Preview Report + + } + onClick={() => setShowReportSettings((v) => !v)} + > + Report Settings + +
+ + {showReportSettings && ( + { + setRecipientName(v); + try { + localStorage.setItem("wl_recipient_name", v); + } catch {} + }} + /> + )}
- )} +
- {/* ── Headgates ── */} -
-
-

Headgates

- {!showAddHg && ( - setShowAddHg(true)}> - + Add Headgate + {/* ── Headgates ───────────────────────────────────────────── */} + setShowAddHg((v) => !v)}> + {showAddHg ? "Cancel" : "+ Add Headgate"} - )} -
+ ) : null + } + /> - {showAddHg && ( -
- setNewHgName(e.target.value)} - placeholder="Headgate name" - className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" - autoFocus - /> - - + {showAddHg && canManage && ( + + setNewHg((s) => ({ ...s, name: v }))} + placeholder="e.g. North Field Gate 1" + required + autoFocus + /> + setNewHg((s) => ({ ...s, notes: v }))} + placeholder="Location, GPS, contact…" + /> +
+ Add - { setShowAddHg(false); setNewHgName(""); }}>Cancel - - )} - -
- {headgates.length === 0 ? ( -

No headgates yet

- ) : ( - - - - - - - - - - - - {headgates.map((hg) => ( - - - - - - - - ))} - -
NameUnitStatusCreated
{hg.name}{hg.unit} - - {hg.active ? "Active" : "Inactive"} - - {formatDateTime(hg.created_at)} - -
- )} -
-
- - {/* ── Water Users ── */} -
-
-
-

Water Users

-

- Admin = manage headgates, users, entries  |  - Irrigator = submit entries only -

- {!showAddUser && ( - setShowAddUser(true)}> - + Add User + + )} + + {headgates.length === 0 ? ( + } + title="No headgates yet" + body="Add a headgate to start logging. Each one gets a unique QR code you can print and post at the gate." + /> + ) : ( +
    + {headgates.map((h) => { + const last = lastEntryByHeadgate.get(h.id); + const level = + last && h.high_threshold + ? Math.min(1, last.measurement / Number(h.high_threshold)) + : null; + return ( +
  • +
    + +
    +
    +

    + {h.name} +

    + +
    +

    + Unit: {h.unit} · Token: {h.headgate_token.slice(0, 8)}… +

    + {last ? ( +

    + Last:{" "} + + {last.measurement} {h.unit} + {" "} + + by {last.user_name} · {formatDateTime(last.logged_at)} + +

    + ) : ( +

    + No readings yet +

    + )} + {(h.high_threshold != null || h.low_threshold != null) && ( +

    + {h.high_threshold != null && ( + <>Hi ≥ {h.high_threshold}   + )} + {h.low_threshold != null && ( + <>Lo ≤ {h.low_threshold} + )} +

    + )} +
    +
    + + {canManage && ( +
    + +
    + + · + +
    +
    + )} +
  • + ); + })} +
+ )} + + {/* ── Water Users ──────────────────────────────────────────── */} + setShowAddUser((v) => !v)}> + {showAddUser ? "Cancel" : "+ Add User"}
- )} -
+ ) : null + } + /> - {showAddUser && ( -
-
-
- - setNewUserName(e.target.value)} - placeholder="Full name" - className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" - autoFocus - /> -
-
- - -
-
- - -
-
-
- - Add User - - { setShowAddUser(false); setNewUserName(""); setNewUserRole("irrigator"); setNewUserLang("en"); }}> - Cancel - -
-
- )} + {newPin && ( + setNewPin(null)} + /> + )} + {resetPin && ( + setResetPin(null)} + /> + )} - {/* PIN display banner */} - {newPin && ( -
-

New PIN for {newPin.name}:

-

{newPin.pin}

-

Write this down — it will not be shown again.

- + {showAddUser && canManage && ( +
+ setNewUser((s) => ({ ...s, name: v }))} + placeholder="Full name" + required + autoFocus + /> + setNewUser((s) => ({ ...s, lang: v }))} + options={[ + { value: "en", label: "English" }, + { value: "es", label: "Español" }, + ]} + /> + setNewUser((s) => ({ ...s, phone: v }))} + placeholder="+1…" + type="tel" + /> +
+ + Add +
- )} +
+ )} -
- {users.length === 0 ? ( -

No water users yet

- ) : ( - - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - ))} - -
NameRoleStatusLanguageLast Used
{user.name} - - {user.role === "water_admin" ? "Admin" : "Irrigator"} - - - - {user.active ? "Active" : "Inactive"} - - - {user.language_preference === "es" ? "Español" : "English"} - {formatDateTime(user.last_used_at)} - -
- )} -
-
+ - {/* ── Entries + CSV Export ── */} -
-
-

Recent Entries

- + {users.length === 0 ? ( + } + title="No water users yet" + body="Add an irrigator or admin to grant field access. Their PIN is generated automatically — write it down before dismissing." + /> + ) : ( +
    + {users.map((u) => ( +
  • + +
    +

    {u.name}

    +

    + {u.role === "water_admin" ? "ADMIN" : "IRRIGATOR"} · {u.language_preference === "es" ? "ES" : "EN"} + {u.last_used_at ? ` · seen ${formatDate(u.last_used_at)}` : " · never signed in"} +

    +
    + {canManage && ( +
    + + · + +
    + )} +
  • + ))} +
+ )} + + {/* ── Recent Entries ──────────────────────────────────────── */} + } + > Export CSV
-
+ } + /> - {/* Filters */} -
+
+ setFilterDateFrom(e.target.value)} - className="rounded-lg border border-[var(--admin-border)] px-2 py-1.5 text-sm" - placeholder="From" + className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" + aria-label="Filter: from date" /> + + setFilterDateTo(e.target.value)} - className="rounded-lg border border-[var(--admin-border)] px-2 py-1.5 text-sm" - placeholder="To" + className="border-0 bg-transparent text-sm text-[#1d1d1f] outline-none" + aria-label="Filter: to date" /> + + + + + + - {(filterDateFrom || filterDateTo || filterHeadgate || filterUser || filterVia) && ( - - )} -
+ + {(filterDateFrom || + filterDateTo || + filterHeadgate || + filterUser || + filterVia) && ( + + )} + + {filteredEntries.length} of {entries.length} entries + +
-
- {filteredEntries.length === 0 ? ( -

No entries{entries.length > 0 ? " match filters" : ""}

- ) : ( - - - - - - - - - + {filteredEntries.length === 0 ? ( + } + title="No entries match" + body={entries.length === 0 ? "No readings have been logged yet." : "Try clearing the filters above."} + /> + ) : ( +
+
WhenUserHeadgateMeasurementVia
+ + + + + + + + + + + + + + {filteredEntries.map((e, i) => ( + + + + + + + + + - - - {filteredEntries.map((e) => ( - - - - - - - - - ))} - -
WhenUserHeadgateMeasurementGallonsMethodVia
+ {formatDateTime(e.logged_at)} + + {e.user_name} + + {e.headgate_name} + + {e.measurement}{" "} + {e.unit} + + {e.total_gallons != null ? e.total_gallons.toFixed(1) : "—"} + + {e.method} + + + {e.submitted_via} + + + +
{formatDateTime(e.logged_at)}{e.user_name}{e.headgate_name}{e.measurement} {e.unit} - - {e.submitted_via} - - - -
- )} + ))} + +
-
+ )} + + {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )}
); -} \ No newline at end of file +} + +// ─── Sub-components ────────────────────────────────────────────────────── + +function SectionHeader({ + index, + title, + subtitle, + action, +}: { + index: string; + title: string; + subtitle: string; + action?: React.ReactNode; +}) { + return ( +
+
+
+ {index} +
+

+ {title} +

+

{subtitle}

+
+ {action} +
+ ); +} + +function StatTile({ + label, + value, + unit, + accent, + mono, +}: { + label: string; + value: string; + unit: string; + accent?: boolean; + mono?: boolean; +}) { + return ( +
+
+ {label} +
+
+ + {value} + + {unit && ( + + {unit} + + )} +
+
+ ); +} + +function StatusPill({ kind }: { kind: "active" | "inactive" | "closed" | "warn" }) { + const map = { + active: { bg: "bg-[#dcfce7]", fg: "text-[#15803d]", label: "Active" }, + inactive: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Inactive" }, + closed: { bg: "bg-[#e8ebe8]", fg: "text-[#57694e]", label: "Closed" }, + warn: { bg: "bg-[#fef9c3]", fg: "text-[#a16207]", label: "Maint." }, + } as const; + const s = map[kind]; + return ( + + {s.label} + + ); +} + +function PinBanner({ + title, + pin, + tone = "ok", + onDismiss, +}: { + title: string; + pin: string; + tone?: "ok" | "warn"; + onDismiss: () => void; +}) { + return ( +
+ +
+

+ {title} +

+

+ {pin} +

+
+ +
+ ); +} + +function RoleLegend() { + return ( +

+ + ADMIN + — manage headgates, users, entries + + · + + IRRIGATOR + — submit entries only + +

+ ); +} + +function EmptyState({ + icon, + title, + body, +}: { + icon: React.ReactNode; + title: string; + body: string; +}) { + return ( +
+
+ {icon} +
+

+ {title} +

+

{body}

+
+ ); +} + +function Input({ + label, + value, + onChange, + placeholder, + required, + autoFocus, + type = "text", +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + required?: boolean; + autoFocus?: boolean; + type?: string; +}) { + return ( + + ); +} + +function Select({ + label, + value, + onChange, + options, +}: { + label: string; + value: string; + onChange: (v: string) => void; + options: (string | { value: string; label: string })[]; +}) { + return ( + + ); +} + +function FilterChip({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function Avatar({ name, role }: { name: string; role: string }) { + const initials = name + .split(/\s+/) + .map((n) => n[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase(); + const bg = role === "water_admin" ? "#1a4d2e" : "#57694e"; + return ( + + {initials || "·"} + + ); +} + +function ReportSettingsPanel({ + season, + onSeasonChange, + recipientName, + onRecipientNameChange, +}: { + season: IrrigationSeasonSettings; + onSeasonChange: (s: IrrigationSeasonSettings) => void; + recipientName: string; + onRecipientNameChange: (v: string) => void; +}) { + return ( +
+
+

+ Report / Messaging Settings +

+
+

+ Preview only. Daily-report delivery is wired through + the scheduled api/cron/water-report endpoint and respects + these settings. +

+
+
+ + Season start + +
+ onSeasonChange({ ...season, seasonStartMonth: m })} + /> + onSeasonChange({ ...season, seasonStartDay: d })} + /> +
+
+
+ + Season end + +
+ onSeasonChange({ ...season, seasonEndMonth: m })} + /> + onSeasonChange({ ...season, seasonEndDay: d })} + /> +
+
+
+ + Recipient name (used in report salutation) + + onRecipientNameChange(e.target.value)} + placeholder="David" + className="w-full rounded-lg border border-[#d4d9d3] px-3 py-2 text-sm outline-none focus:border-[#1a4d2e]" + /> +
+
+
+ ); +} + +function MonthSelect({ + value, + onChange, +}: { + value: number; + onChange: (m: number) => void; +}) { + return ( + + ); +} + +function DayInput({ + value, + onChange, +}: { + value: number; + onChange: (d: number) => void; +}) { + return ( + onChange(parseInt(e.target.value, 10) || 1)} + className="w-16 rounded-lg border border-[#d4d9d3] px-2 py-1.5 text-sm" + aria-label="Day" + /> + ); +} + +// ─── Icons (16–20px, currentColor) ────────────────────────────────────── + +function QrIcon() { + return ( + + + + + + + + + ); +} + +function GearIcon() { + return ( + + + + + ); +} + +function FieldIcon() { + return ( + + + + + ); +} + +function ReportIcon() { + return ( + + + + + ); +} + +function DownloadIcon() { + return ( + + + + + ); +} + +function KeyIcon() { + return ( + + + + + ); +} + +function UserIcon() { + return ( + + + + + ); +} + +function TableIcon() { + return ( + + + + + ); +} diff --git a/src/components/water/WaterAdminClient.tsx b/src/components/water/WaterAdminClient.tsx index 3899abb..feb39c1 100644 --- a/src/components/water/WaterAdminClient.tsx +++ b/src/components/water/WaterAdminClient.tsx @@ -361,8 +361,8 @@ export default function WaterAdminClient() { setSavingUser(true); setNewUserError(null); const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang); - if (result.success && result.pin) { - setNewPin({ name: result.irrigator!.name, pin: result.pin }); + if (result.success && result.pin && result.user) { + setNewPin({ name: result.user.name, pin: result.pin }); setNewUserName(""); setShowAddUser(false); const updated = await getWaterIrrigators(TUXEDO_BRAND_ID); diff --git a/src/components/water/WaterFieldClient.tsx b/src/components/water/WaterFieldClient.tsx index f8c640e..03a34fc 100644 --- a/src/components/water/WaterFieldClient.tsx +++ b/src/components/water/WaterFieldClient.tsx @@ -10,6 +10,7 @@ import { setWaterLang, } from "@/actions/water-log/field"; import { useSearchParams } from "next/navigation"; +import { WaterGauge } from "@/components/water/icons"; type Headgate = { id: string; @@ -345,10 +346,13 @@ function WaterFieldInner() { // Language selection screen if (step === "loading") { return ( -
+
-
-

Loading...

+
+ +
+
+

Loading…

); @@ -357,20 +361,31 @@ function WaterFieldInner() { // Language selection screen if (step === "lang") { return ( -
+
-

{t.title}

-

{t.selectLang}

-
+
+ +
+

+ {t.title} +

+

+ Tuxedo Ditch Co. +

+

{t.selectLang}

+
@@ -383,26 +398,31 @@ function WaterFieldInner() { // Role selection screen if (step === "role") { return ( -
+
-

{t.title}

-

{t.whoAreYou}

-
+

+ {t.title} +

+

{t.whoAreYou}

+
@@ -415,16 +435,21 @@ function WaterFieldInner() { // PIN entry screen if (step === "pin") { return ( -
+
-

{t.title}

-

{t.enterPin}

+

+ {t.title} +

+

{t.enterPin}

setPin(e.target.value.replace(/\D/g, "").slice(0, 4))} 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" }} autoFocus /> {error && ( -

{error}

+

{error}

)} @@ -459,17 +484,27 @@ function WaterFieldInner() { const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate); return ( -
+
{/* Header */} -
+
-
-

{t.title}

-

{t.loggedInAs}: {irrigatorName}

+
+ +
+

+ {t.title} +

+

+ {t.loggedInAs}: {irrigatorName} +

+
@@ -477,7 +512,7 @@ function WaterFieldInner() {
{/* Step progress indicator */} -
+
{["lang", "role", "pin", "form"].map((s, i) => { @@ -486,8 +521,8 @@ function WaterFieldInner() { const isPast = stepIndex > i; return (
-
- {i < 3 &&
} +
+ {i < 3 &&
}
); })} diff --git a/src/components/water/icons/SeasonMark.tsx b/src/components/water/icons/SeasonMark.tsx new file mode 100644 index 0000000..46f1a83 --- /dev/null +++ b/src/components/water/icons/SeasonMark.tsx @@ -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 ( + + + {[ + [8, 1], + [8, 15], + [1, 8], + [15, 8], + [3, 3], + [13, 13], + [3, 13], + [13, 3], + ].map(([cx, cy], i) => ( + + ))} + + ); + } + return ( + + + + + + + {/* Arrow heads */} + + + + + + + + + + + ); +} diff --git a/src/components/water/icons/WaterGauge.tsx b/src/components/water/icons/WaterGauge.tsx new file mode 100644 index 0000000..b7ad97d --- /dev/null +++ b/src/components/water/icons/WaterGauge.tsx @@ -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. + * + * + * + */ + +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 ( + + {/* Top "headgate" — a small handled gate */} + + {/* Gate top bar */} + + {/* Gate handle */} + + {/* Gate frame */} + + {/* Gate wheel */} + + + + {/* The ditch / pipe */} + + + {/* Tick marks at quarter / half / three-quarter */} + + + + + {/* Water level — clipped inside the pipe */} + + + + + + {/* Wavy water surface — three short arcs */} + {l > 0 && ( + + )} + + + {/* Status dot — top-right corner */} + + + ); +} diff --git a/src/components/water/icons/index.ts b/src/components/water/icons/index.ts new file mode 100644 index 0000000..1ca44ca --- /dev/null +++ b/src/components/water/icons/index.ts @@ -0,0 +1,2 @@ +export { WaterGauge } from "./WaterGauge"; +export { SeasonMark } from "./SeasonMark"; diff --git a/src/lib/water-log-audit.ts b/src/lib/water-log-audit.ts new file mode 100644 index 0000000..83d3350 --- /dev/null +++ b/src/lib/water-log-audit.ts @@ -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 | 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 { + 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 { + 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); + } +} diff --git a/src/lib/water-log-pin.ts b/src/lib/water-log-pin.ts new file mode 100644 index 0000000..a463030 --- /dev/null +++ b/src/lib/water-log-pin.ts @@ -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"); +} diff --git a/tests/unit/water-log-pin.test.ts b/tests/unit/water-log-pin.test.ts new file mode 100644 index 0000000..5ae43f4 --- /dev/null +++ b/tests/unit/water-log-pin.test.ts @@ -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(); + 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 4–8 digits", () => { + expect(PIN_MIN).toBe(4); + expect(PIN_MAX).toBe(8); + }); +}); diff --git a/tests/unit/water-log-reporting.test.ts b/tests/unit/water-log-reporting.test.ts new file mode 100644 index 0000000..6177572 --- /dev/null +++ b/tests/unit/water-log-reporting.test.ts @@ -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 => ({ + 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 + }); +}); diff --git a/tests/water-log.spec.ts b/tests/water-log.spec.ts new file mode 100644 index 0000000..f779b6c --- /dev/null +++ b/tests/water-log.spec.ts @@ -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(); + }); +});