Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.
Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log
Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns
Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages
API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers
UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac
Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates
Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment
Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
@@ -68,3 +68,14 @@ MINIO_BUCKET_WATER_LOGS=route-water-logs
|
|||||||
|
|
||||||
# ── Cron / automation ───────────────────────────────────────────────────────
|
# ── Cron / automation ───────────────────────────────────────────────────────
|
||||||
CRON_SECRET=replace-me-with-a-random-string
|
CRON_SECRET=replace-me-with-a-random-string
|
||||||
|
|
||||||
|
# ── Water Log ───────────────────────────────────────────────────────────────
|
||||||
|
# The Water Log module reuses the existing `DATABASE_URL` and (for photo
|
||||||
|
# uploads) the `MINIO_BUCKET_WATER_LOGS` bucket above. It also reuses the
|
||||||
|
# brand's Twilio / SMS config for high/low threshold alerts. The
|
||||||
|
# Tuxedo brand UUID is a public default used as a fallback for cold-start
|
||||||
|
# paths when the calling admin user is a platform_admin with brand_id=null.
|
||||||
|
# It does NOT need to be set as an env var — the value is hardcoded in
|
||||||
|
# the server actions — but you can override it with:
|
||||||
|
# TUXEDO_BRAND_ID=64294306-5f42-463d-a5e8-2ad6c81a96de
|
||||||
|
# See docs/water-log.md for the full module guide.
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ The admin dashboard lives at `/admin`:
|
|||||||
- **Communications** — Harvest Reach campaign manager
|
- **Communications** — Harvest Reach campaign manager
|
||||||
- **Wholesale** — Wholesale portal settings
|
- **Wholesale** — Wholesale portal settings
|
||||||
- **Billing** — Plan and subscription management
|
- **Billing** — Plan and subscription management
|
||||||
- **Water Log** — Irrigation tracking (add-on)
|
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
|
||||||
- **Settings** — Brand settings, payments, apps
|
- **Settings** — Brand settings, payments, apps
|
||||||
|
|
||||||
## Email Automations (Harvest Reach)
|
## Email Automations (Harvest Reach)
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
-- 0090_water_log_completion.sql
|
||||||
|
--
|
||||||
|
-- Water Log feature completion. The original `0001_init.sql` shipped the
|
||||||
|
-- core five tables (headgates, irrigators, sessions, log entries, alerts)
|
||||||
|
-- with RLS, but the SaaS rebuild's actions and UIs depend on a wider
|
||||||
|
-- surface that this migration adds:
|
||||||
|
--
|
||||||
|
-- - `water_headgates.headgate_token` — opaque per-gate token for QR
|
||||||
|
-- - `water_headgates.status` — open / closed / maintenance
|
||||||
|
-- - `water_headgates.max_flow_gpm` — optional high-water marker
|
||||||
|
-- - `water_headgates.notes` — free-form location notes
|
||||||
|
-- - `water_headgates.last_used_at` — denormalized for display
|
||||||
|
-- - `water_headgates.high_threshold` — optional alert ceiling
|
||||||
|
-- - `water_headgates.low_threshold` — optional alert floor
|
||||||
|
-- - `water_headgates.unit` — display unit (CFS/GPM/etc.)
|
||||||
|
--
|
||||||
|
-- - `water_irrigators.role` — "irrigator" | "water_admin"
|
||||||
|
-- - `water_irrigators.phone` — optional contact
|
||||||
|
-- - `water_irrigators.notes` — free-form
|
||||||
|
--
|
||||||
|
-- - `water_log_entries.method` — "manual" | "meter" | "estimate" | "qr"
|
||||||
|
-- - `water_log_entries.total_gallons` — derived when computable
|
||||||
|
-- - `water_log_entries.photo_url` — link to storage object
|
||||||
|
-- - `water_log_entries.logged_date` — date-only (YYYY-MM-DD) for fast grouping
|
||||||
|
-- - `water_log_entries.latitude`/`longitude` — optional GPS pin
|
||||||
|
-- - `water_log_entries.brand_id` index upgrade
|
||||||
|
--
|
||||||
|
-- - NEW `water_admin_settings` (per-brand admin PIN + alert config)
|
||||||
|
-- - NEW `water_admin_sessions` (admin sign-in sessions, separate cookie)
|
||||||
|
-- - NEW `water_audit_log` (who changed what, when)
|
||||||
|
--
|
||||||
|
-- All new tables follow project conventions:
|
||||||
|
-- - TIMESTAMPTZ for timestamps
|
||||||
|
-- - UUID PKs via gen_random_uuid()
|
||||||
|
-- - brand_id scoped, RLS enabled, FORCE'd
|
||||||
|
-- - CREATE TABLE IF NOT EXISTS for re-runnability
|
||||||
|
--
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ─── 1. Extend water_headgates ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE water_headgates
|
||||||
|
ADD COLUMN IF NOT EXISTS headgate_token TEXT UNIQUE
|
||||||
|
DEFAULT encode(gen_random_bytes(12), 'hex'),
|
||||||
|
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'
|
||||||
|
CHECK (status IN ('open', 'closed', 'maintenance')),
|
||||||
|
ADD COLUMN IF NOT EXISTS max_flow_gpm NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS high_threshold NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS low_threshold NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
|
||||||
|
|
||||||
|
-- Backfill any existing rows that pre-date the default expression
|
||||||
|
UPDATE water_headgates
|
||||||
|
SET headgate_token = encode(gen_random_bytes(12), 'hex')
|
||||||
|
WHERE headgate_token IS NULL;
|
||||||
|
|
||||||
|
-- ─── 2. Extend water_irrigators ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE water_irrigators
|
||||||
|
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'irrigator'
|
||||||
|
CHECK (role IN ('irrigator', 'water_admin')),
|
||||||
|
ADD COLUMN IF NOT EXISTS phone TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
|
|
||||||
|
-- ─── 3. Extend water_log_entries ────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE water_log_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (method IN ('manual', 'meter', 'estimate', 'qr')),
|
||||||
|
ADD COLUMN IF NOT EXISTS total_gallons NUMERIC,
|
||||||
|
ADD COLUMN IF NOT EXISTS photo_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS logged_date DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION;
|
||||||
|
|
||||||
|
-- Backfill logged_date for any rows that pre-date the column
|
||||||
|
UPDATE water_log_entries
|
||||||
|
SET logged_date = (logged_at AT TIME ZONE 'UTC')::date
|
||||||
|
WHERE logged_date IS NULL AND logged_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Index for fast "today's entries" + "this week's entries" queries
|
||||||
|
CREATE INDEX IF NOT EXISTS water_log_entries_brand_date_idx
|
||||||
|
ON water_log_entries (brand_id, logged_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS water_log_entries_irrigator_idx
|
||||||
|
ON water_log_entries (irrigator_id, logged_at DESC);
|
||||||
|
|
||||||
|
-- ─── 4. water_admin_settings (per brand) ───────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_admin_settings (
|
||||||
|
brand_id UUID PRIMARY KEY REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
session_duration_hours INTEGER NOT NULL DEFAULT 4
|
||||||
|
CHECK (session_duration_hours BETWEEN 1 AND 168),
|
||||||
|
can_edit_entries BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
can_delete_entries BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
can_export_csv BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
alert_phone TEXT,
|
||||||
|
alerts_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_by UUID REFERENCES admin_users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger t
|
||||||
|
JOIN pg_class c ON t.tgrelid = c.oid
|
||||||
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||||
|
WHERE t.tgname = 'water_admin_settings_updated_at' AND n.nspname = current_schema()
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER water_admin_settings_updated_at BEFORE UPDATE ON water_admin_settings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── 5. water_admin_sessions (separate from irrigator sessions) ───────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_admin_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
pin_hash_used TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS water_admin_sessions_admin_idx
|
||||||
|
ON water_admin_sessions (admin_user_id, expires_at DESC);
|
||||||
|
|
||||||
|
-- ─── 6. water_audit_log (who changed what, when) ───────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS water_audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||||
|
actor_id UUID REFERENCES admin_users(id),
|
||||||
|
actor_label TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id UUID,
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS water_audit_log_brand_recent_idx
|
||||||
|
ON water_audit_log (brand_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ─── 7. RLS for new tables ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE water_admin_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE water_admin_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE water_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE water_admin_settings FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE water_admin_sessions FORCE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE water_audit_log FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_admin_settings;
|
||||||
|
CREATE POLICY tenant_isolation ON water_admin_settings FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_admin_sessions;
|
||||||
|
CREATE POLICY tenant_isolation ON water_admin_sessions FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON water_audit_log;
|
||||||
|
CREATE POLICY tenant_isolation ON water_audit_log FOR ALL
|
||||||
|
USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||||
|
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||||
+144
-2
@@ -1,5 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Water Log. Source: `db/migrations/0001_init.sql`.
|
* Water Log. Source: `db/migrations/0001_init.sql` + `0090_water_log_completion.sql`.
|
||||||
|
*
|
||||||
|
* Six tables, all brand-scoped with RLS:
|
||||||
|
* - water_headgates — physical gates a measurement is tied to
|
||||||
|
* - water_irrigators — PIN-authenticated field workers
|
||||||
|
* - water_sessions — short-lived PIN sessions for irrigators
|
||||||
|
* - water_log_entries — the actual reading logs
|
||||||
|
* - water_alert_log — high/low threshold alert history
|
||||||
|
* - water_admin_settings — per-brand admin PIN + alert config
|
||||||
|
* - water_admin_sessions — admin sign-in sessions (separate cookie)
|
||||||
|
* - water_audit_log — who changed what, when
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -8,7 +18,13 @@ import {
|
|||||||
numeric,
|
numeric,
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
date,
|
||||||
|
jsonb,
|
||||||
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
integer,
|
||||||
|
check,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { brands } from "./brands";
|
import { brands } from "./brands";
|
||||||
import { adminUsers } from "./brands";
|
import { adminUsers } from "./brands";
|
||||||
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
/** Per-headgate opaque token used in the QR code. */
|
||||||
|
headgateToken: text("headgate_token").notNull().unique(),
|
||||||
|
/** Open / Closed / Maintenance. */
|
||||||
|
status: text("status").notNull().default("open"),
|
||||||
|
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
|
||||||
|
unit: text("unit").notNull().default("CFS"),
|
||||||
|
/** Optional max-flow marker in GPM. */
|
||||||
|
maxFlowGpm: numeric("max_flow_gpm"),
|
||||||
|
/** High-water alert threshold (units match `unit`). */
|
||||||
|
highThreshold: numeric("high_threshold"),
|
||||||
|
/** Low-water alert threshold (units match `unit`). */
|
||||||
|
lowThreshold: numeric("low_threshold"),
|
||||||
|
notes: text("notes"),
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
|
(t) => ({
|
||||||
|
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
|
||||||
|
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const waterIrrigators = pgTable(
|
export const waterIrrigators = pgTable(
|
||||||
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
|
|||||||
languagePreference: text("language_preference")
|
languagePreference: text("language_preference")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("en"),
|
.default("en"),
|
||||||
|
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||||
|
role: text("role").notNull().default("irrigator"),
|
||||||
|
phone: text("phone"),
|
||||||
|
notes: text("notes"),
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
|
(t) => ({
|
||||||
|
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const waterSessions = pgTable(
|
export const waterSessions = pgTable(
|
||||||
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
|
|||||||
irrigatorId: uuid("irrigator_id")
|
irrigatorId: uuid("irrigator_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||||
|
/** Raw measurement value, in the entry's `unit`. */
|
||||||
measurement: numeric("measurement").notNull(),
|
measurement: numeric("measurement").notNull(),
|
||||||
unit: text("unit").notNull(),
|
unit: text("unit").notNull(),
|
||||||
|
/** "manual" | "meter" | "estimate" | "qr" */
|
||||||
|
method: text("method").notNull().default("manual"),
|
||||||
|
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
|
||||||
|
totalGallons: numeric("total_gallons"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
submittedVia: text("submitted_via").notNull().default("app"),
|
submittedVia: text("submitted_via").notNull().default("app"),
|
||||||
|
photoUrl: text("photo_url"),
|
||||||
|
latitude: doublePrecision("latitude"),
|
||||||
|
longitude: doublePrecision("longitude"),
|
||||||
|
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
|
||||||
|
loggedDate: date("logged_date"),
|
||||||
loggedAt: timestamp("logged_at", { withTimezone: true })
|
loggedAt: timestamp("logged_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
|
|||||||
(t) => ({
|
(t) => ({
|
||||||
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
||||||
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
||||||
|
brandDateIdx: index("water_log_entries_brand_date_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.loggedDate,
|
||||||
|
),
|
||||||
|
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||||
|
t.irrigatorId,
|
||||||
|
t.loggedAt,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => brands.id, { onDelete: "cascade" }),
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
alertType: text("alert_type").notNull(),
|
alertType: text("alert_type").notNull(),
|
||||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
|
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
message: text("message").notNull(),
|
message: text("message").notNull(),
|
||||||
sentTo: text("sent_to"),
|
sentTo: text("sent_to"),
|
||||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||||
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const waterAdminSettings = pgTable("water_admin_settings", {
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
|
||||||
|
pinHash: text("pin_hash"),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
sessionDurationHours: integer("session_duration_hours")
|
||||||
|
.notNull()
|
||||||
|
.default(4),
|
||||||
|
canEditEntries: boolean("can_edit_entries").notNull().default(true),
|
||||||
|
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
|
||||||
|
canExportCsv: boolean("can_export_csv").notNull().default(true),
|
||||||
|
alertPhone: text("alert_phone"),
|
||||||
|
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedBy: uuid("updated_by").references(() => adminUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const waterAdminSessions = pgTable(
|
||||||
|
"water_admin_sessions",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
adminUserId: uuid("admin_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
||||||
|
pinHashUsed: text("pin_hash_used").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
adminIdx: index("water_admin_sessions_admin_idx").on(
|
||||||
|
t.adminUserId,
|
||||||
|
t.expiresAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const waterAuditLog = pgTable(
|
||||||
|
"water_audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brandId: uuid("brand_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brands.id, { onDelete: "cascade" }),
|
||||||
|
actorId: uuid("actor_id").references(() => adminUsers.id),
|
||||||
|
actorLabel: text("actor_label").notNull(),
|
||||||
|
action: text("action").notNull(),
|
||||||
|
entityType: text("entity_type").notNull(),
|
||||||
|
entityId: uuid("entity_id"),
|
||||||
|
details: jsonb("details"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
|
||||||
|
t.brandId,
|
||||||
|
t.createdAt,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Inferred types (used by every action and client component) ────────────
|
||||||
|
|
||||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||||
|
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||||
|
|
||||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||||
|
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||||
|
|
||||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||||
|
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
|
||||||
|
|
||||||
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
||||||
|
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||||
|
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||||
|
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||||
|
|||||||
@@ -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
|
||||||
+957
-191
File diff suppressed because it is too large
Load Diff
+399
-62
@@ -1,88 +1,374 @@
|
|||||||
|
/**
|
||||||
|
* Water Log — field (PIN) actions.
|
||||||
|
*
|
||||||
|
* These power `/water` and `/water/admin` — the mobile-first, PIN-only
|
||||||
|
* portals that ditch-riders actually use in the field. The shape of
|
||||||
|
* the data they consume is the same as admin actions, but the
|
||||||
|
* authorization model is different:
|
||||||
|
*
|
||||||
|
* - Irrigators carry a `wl_session` cookie tied to a row in
|
||||||
|
* `water_sessions`. Cookie expiry = row's `expires_at`.
|
||||||
|
* - Admins (water_admin role) carry a `wl_admin_session` cookie
|
||||||
|
* tied to `water_admin_sessions`.
|
||||||
|
*
|
||||||
|
* Both cookies are httpOnly + sameSite=lax. The session lookup is the
|
||||||
|
* gate for every mutating action — there's no role/permission check
|
||||||
|
* inside the action because the cookie presence + the row's role is
|
||||||
|
* the permission.
|
||||||
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||||
|
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||||
|
import {
|
||||||
|
waterHeadgates,
|
||||||
|
waterIrrigators,
|
||||||
|
waterSessions,
|
||||||
|
waterLogEntries,
|
||||||
|
waterAdminSessions,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
|
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||||
|
import { logAlert } from "@/lib/water-log-audit";
|
||||||
|
|
||||||
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
|
// Field sessions last 8h — a working day in the field. Long enough
|
||||||
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
|
// that a single sign-in covers a morning shift + afternoon shift.
|
||||||
// `submit_water_entry`, `trigger_water_alert`,
|
const FIELD_SESSION_HOURS = 8;
|
||||||
// `get_water_admin_session`) and tables (`water_headgates`,
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
// `water_users`, `water_sessions`, `water_admin_sessions`,
|
|
||||||
// `water_entries`, `water_alert_log`) that are not in the SaaS
|
|
||||||
// rebuild's `db/schema/`. The actions below preserve the original
|
|
||||||
// signatures and return empty / no-op responses so the field UI
|
|
||||||
// degrades gracefully. See `actions/route-trace/lots.ts` for the
|
|
||||||
// same pattern.
|
|
||||||
|
|
||||||
type VerifyPinResult = {
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
success: true;
|
|
||||||
user_id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
session_id: string;
|
|
||||||
lang: string;
|
|
||||||
} | {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubmitEntryResult = {
|
type FieldHeadgate = {
|
||||||
success: true;
|
|
||||||
entry_id: string;
|
|
||||||
} | {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Headgate = {
|
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
unit: string;
|
||||||
|
status: string;
|
||||||
|
high_threshold: number | null;
|
||||||
|
low_threshold: number | null;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
created_at: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
type VerifyPinResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
role: "irrigator" | "water_admin";
|
||||||
|
session_id: string;
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
type SubmitEntryResult =
|
||||||
|
| { success: true; entry_id: string }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// ── Headgates (read-only for field) ────────────────────────────────────────
|
||||||
|
|
||||||
export async function getWaterHeadgates(
|
export async function getWaterHeadgates(
|
||||||
_brandId: string,
|
_brandId: string,
|
||||||
_activeOnly = false
|
activeOnly: boolean = false,
|
||||||
): Promise<Headgate[]> {
|
): Promise<FieldHeadgate[]> {
|
||||||
return [];
|
return withBrand(TUXEDO_BRAND_ID, async (db) => {
|
||||||
|
const where = activeOnly
|
||||||
|
? and(
|
||||||
|
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
|
||||||
|
eq(waterHeadgates.active, true),
|
||||||
|
)
|
||||||
|
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterHeadgates)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(waterHeadgates.name);
|
||||||
|
return rows.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
name: h.name,
|
||||||
|
unit: h.unit,
|
||||||
|
status: h.status,
|
||||||
|
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
||||||
|
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
||||||
|
active: h.active,
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PIN verify + session ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function verifyWaterPin(
|
export async function verifyWaterPin(
|
||||||
_brandId: string,
|
_brandId: string,
|
||||||
_pin: string
|
pin: string,
|
||||||
): Promise<VerifyPinResult> {
|
): Promise<VerifyPinResult> {
|
||||||
return { success: false, error: NOT_CONFIGURED };
|
// Validate format first (cheap, no DB).
|
||||||
}
|
const formatError = validatePin(pin);
|
||||||
|
if (formatError) return { success: false, error: formatError };
|
||||||
|
|
||||||
export async function submitWaterEntry(
|
// Look the irrigator up across all brands (PIN is the only credential).
|
||||||
_headgateId: string,
|
const lookup = await withPlatformAdmin(async (db) => {
|
||||||
_measurement: number,
|
const rows = await db
|
||||||
_unit: string,
|
.select()
|
||||||
_notes: string,
|
.from(waterIrrigators)
|
||||||
_photoUrl?: string,
|
.where(eq(waterIrrigators.active, true))
|
||||||
_latitude?: number,
|
.orderBy(waterIrrigators.name);
|
||||||
_longitude?: number,
|
return rows;
|
||||||
_headgateLocked?: boolean
|
});
|
||||||
): Promise<SubmitEntryResult> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const sessionId = cookieStore.get("wl_session")?.value;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
// Constant-time-ish: check every row, only succeed on the first match.
|
||||||
return { success: false, error: "Not logged in" };
|
// (We can't make this perfectly constant-time without pulling the
|
||||||
|
// hashed values into memory in random order, but iterating by name
|
||||||
|
// is close enough for a 4-digit PIN and avoids the worst case of an
|
||||||
|
// obvious timing oracle on a sorted list.)
|
||||||
|
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
|
||||||
|
if (!match) {
|
||||||
|
// Add a small delay to discourage brute-force scanning.
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
return { success: false, error: "Invalid PIN" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, error: NOT_CONFIGURED };
|
// Create a session in the user's brand context.
|
||||||
|
const sessionId = await withBrand(match.brandId, async (db) => {
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
const [row] = await db
|
||||||
|
.insert(waterSessions)
|
||||||
|
.values({
|
||||||
|
irrigatorId: match.id,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({ id: waterSessions.id });
|
||||||
|
if (!row) throw new Error("Failed to create session");
|
||||||
|
// Bump last_used_at for "I haven't seen this person in a while" UX
|
||||||
|
await db
|
||||||
|
.update(waterIrrigators)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(waterIrrigators.id, match.id));
|
||||||
|
return row.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set("wl_session", sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: FIELD_SESSION_HOURS * 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user_id: match.id,
|
||||||
|
name: match.name,
|
||||||
|
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||||
|
session_id: sessionId,
|
||||||
|
lang: match.languagePreference,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Submit entry ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitWaterEntry(
|
||||||
|
headgateId: string,
|
||||||
|
measurement: number,
|
||||||
|
unit: string,
|
||||||
|
notes: string,
|
||||||
|
photoUrl?: string,
|
||||||
|
latitude?: number,
|
||||||
|
longitude?: number,
|
||||||
|
_headgateLocked?: boolean,
|
||||||
|
): Promise<SubmitEntryResult> {
|
||||||
|
// ── Auth ──
|
||||||
|
const session = await requireFieldSession();
|
||||||
|
if (!session.ok) return { success: false, error: session.error };
|
||||||
|
|
||||||
|
// ── Validate input ──
|
||||||
|
if (!Number.isFinite(measurement) || measurement < 0) {
|
||||||
|
return { success: false, error: "Measurement must be a non-negative number" };
|
||||||
|
}
|
||||||
|
if (measurement > 1_000_000) {
|
||||||
|
return { success: false, error: "Measurement is implausibly large" };
|
||||||
|
}
|
||||||
|
if (notes && notes.length > 500) {
|
||||||
|
return { success: false, error: "Notes are too long (max 500 chars)" };
|
||||||
|
}
|
||||||
|
if (latitude != null && (latitude < -90 || latitude > 90)) {
|
||||||
|
return { success: false, error: "Invalid latitude" };
|
||||||
|
}
|
||||||
|
if (longitude != null && (longitude < -180 || longitude > 180)) {
|
||||||
|
return { success: false, error: "Invalid longitude" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write the entry inside the brand context ──
|
||||||
|
return withBrand(session.brandId, async (db) => {
|
||||||
|
// Confirm the headgate belongs to this brand
|
||||||
|
const headgateRows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterHeadgates)
|
||||||
|
.where(eq(waterHeadgates.id, headgateId))
|
||||||
|
.limit(1);
|
||||||
|
const headgate = headgateRows[0];
|
||||||
|
if (!headgate) return { success: false, error: "Headgate not found" };
|
||||||
|
|
||||||
|
// Auto-calc total_gallons when CFS × duration is provided.
|
||||||
|
// Caller passes `measurement` as the total CFS volume for the set
|
||||||
|
// (we don't track duration separately on the entry — duration goes
|
||||||
|
// in notes if needed). We leave total_gallons null for CFS by
|
||||||
|
// default to avoid spurious "X gallons" claims.
|
||||||
|
const totalGallons = computeTotalGallons(measurement, unit);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`;
|
||||||
|
|
||||||
|
const [entry] = await db
|
||||||
|
.insert(waterLogEntries)
|
||||||
|
.values({
|
||||||
|
brandId: session.brandId,
|
||||||
|
headgateId,
|
||||||
|
irrigatorId: session.userId,
|
||||||
|
measurement: measurement.toString(),
|
||||||
|
unit,
|
||||||
|
totalGallons: totalGallons?.toString() ?? null,
|
||||||
|
method: "manual",
|
||||||
|
notes: notes || null,
|
||||||
|
submittedVia: "field",
|
||||||
|
photoUrl: photoUrl ?? null,
|
||||||
|
latitude: latitude ?? null,
|
||||||
|
longitude: longitude ?? null,
|
||||||
|
loggedDate,
|
||||||
|
loggedAt: now,
|
||||||
|
})
|
||||||
|
.returning({ id: waterLogEntries.id });
|
||||||
|
if (!entry) return { success: false, error: "Insert failed" };
|
||||||
|
|
||||||
|
// Bump headgate last_used_at
|
||||||
|
await db
|
||||||
|
.update(waterHeadgates)
|
||||||
|
.set({ lastUsedAt: now })
|
||||||
|
.where(eq(waterHeadgates.id, headgateId));
|
||||||
|
|
||||||
|
// Threshold alert check (fire-and-forget)
|
||||||
|
if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) {
|
||||||
|
void logAlert({
|
||||||
|
brandId: session.brandId,
|
||||||
|
headgateId,
|
||||||
|
alertType: "high",
|
||||||
|
reading: measurement,
|
||||||
|
threshold: Number(headgate.highThreshold),
|
||||||
|
headgateName: headgate.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) {
|
||||||
|
void logAlert({
|
||||||
|
brandId: session.brandId,
|
||||||
|
headgateId,
|
||||||
|
alertType: "low",
|
||||||
|
reading: measurement,
|
||||||
|
threshold: Number(headgate.lowThreshold),
|
||||||
|
headgateName: headgate.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, entry_id: entry.id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
|
||||||
|
|
||||||
|
type FieldSession =
|
||||||
|
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function requireFieldSession(): Promise<FieldSession> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("wl_session")?.value;
|
||||||
|
if (!sessionId) return { ok: false, error: "Not logged in" };
|
||||||
|
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
session: waterSessions,
|
||||||
|
irrigator: waterIrrigators,
|
||||||
|
})
|
||||||
|
.from(waterSessions)
|
||||||
|
.innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId))
|
||||||
|
.where(eq(waterSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return { ok: false as const, error: "Session not found" };
|
||||||
|
if (row.session.expiresAt.getTime() < Date.now()) {
|
||||||
|
// Best-effort cleanup
|
||||||
|
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
|
||||||
|
return { ok: false as const, error: "Session expired" };
|
||||||
|
}
|
||||||
|
if (!row.irrigator.active) {
|
||||||
|
return { ok: false as const, error: "User is inactive" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
userId: row.irrigator.id,
|
||||||
|
brandId: row.irrigator.brandId,
|
||||||
|
role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFieldSessionUser(): Promise<{
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
brandId: string;
|
||||||
|
role: "irrigator" | "water_admin";
|
||||||
|
} | null> {
|
||||||
|
const s = await requireFieldSession();
|
||||||
|
if (!s.ok) return null;
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({ name: waterIrrigators.name, role: waterIrrigators.role })
|
||||||
|
.from(waterIrrigators)
|
||||||
|
.where(eq(waterIrrigators.id, s.userId))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
userId: s.userId,
|
||||||
|
name: row.name,
|
||||||
|
brandId: s.brandId,
|
||||||
|
role: (row.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cookie/language helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function logoutWater(): Promise<void> {
|
export async function logoutWater(): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("wl_session")?.value;
|
||||||
|
if (sessionId) {
|
||||||
|
// Best-effort DB cleanup
|
||||||
|
try {
|
||||||
|
await withPlatformAdmin(async (db) => {
|
||||||
|
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore — the cookie is being deleted anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
cookieStore.delete("wl_session");
|
cookieStore.delete("wl_session");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutWaterAdmin(): Promise<void> {
|
export async function logoutWaterAdmin(): Promise<void> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
await withPlatformAdmin(async (db) => {
|
||||||
|
await db
|
||||||
|
.delete(waterAdminSessions)
|
||||||
|
.where(eq(waterAdminSessions.id, sessionId));
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore — cookie is being deleted anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
cookieStore.delete("wl_admin_session");
|
cookieStore.delete("wl_admin_session");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
|
|||||||
return cookieStore.get("wl_session")?.value ?? null;
|
return cookieStore.get("wl_session")?.value ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current `/water/admin` session.
|
||||||
|
*
|
||||||
|
* Returns the admin role + brandId on success, or `null` if the cookie
|
||||||
|
* is missing / expired / points at a deleted session row. Used by
|
||||||
|
* admin pages (entries/[id], headgates/[id], users/[id]) as a *second*
|
||||||
|
* gate on top of `getAdminUser()` — a site admin can edit entries
|
||||||
|
* directly, but a PIN-authenticated water admin can too.
|
||||||
|
*/
|
||||||
|
export async function getWaterAdminSession(): Promise<{
|
||||||
|
sessionId: string;
|
||||||
|
brandId: string;
|
||||||
|
adminUserId: string;
|
||||||
|
role: "water_admin";
|
||||||
|
} | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return withPlatformAdmin(async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: waterAdminSessions.id,
|
||||||
|
brandId: waterAdminSessions.brandId,
|
||||||
|
adminUserId: waterAdminSessions.adminUserId,
|
||||||
|
expiresAt: waterAdminSessions.expiresAt,
|
||||||
|
})
|
||||||
|
.from(waterAdminSessions)
|
||||||
|
.where(eq(waterAdminSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expiresAt.getTime() <= Date.now()) return null;
|
||||||
|
return {
|
||||||
|
sessionId: row.id,
|
||||||
|
brandId: row.brandId,
|
||||||
|
adminUserId: row.adminUserId,
|
||||||
|
role: "water_admin" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function setWaterLang(lang: string): Promise<void> {
|
export async function setWaterLang(lang: string): Promise<void> {
|
||||||
|
if (!["en", "es"].includes(lang)) return;
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set("wl_lang", lang, {
|
cookieStore.set("wl_lang", lang, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWaterAdminSession(): Promise<{
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||||
user_id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
} | null> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
|
||||||
|
|
||||||
if (!sessionId) return null;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the measurement is in CFS (cubic feet per second) and we know it
|
||||||
|
* represents a set duration, we can derive total gallons. Without
|
||||||
|
* duration, we leave total_gallons null to avoid making up numbers.
|
||||||
|
*
|
||||||
|
* CFS × 60s × 7.48052 = gallons per minute.
|
||||||
|
* 1 CFS for 1 hour = ~448.83 gallons.
|
||||||
|
*/
|
||||||
|
function computeTotalGallons(measurement: number, unit: string): number | null {
|
||||||
|
if (unit !== "CFS") return null;
|
||||||
|
// We don't have a duration field on the entry, so we return null. The
|
||||||
|
// admin can edit an entry to set gallons manually if they have that
|
||||||
|
// data. The hook is here so callers can extend easily.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Water Log — admin settings (PIN-protected /water/admin portal).
|
||||||
|
*
|
||||||
|
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
|
||||||
|
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
||||||
|
* `water_admin_settings` (one row per brand). Sessions are tracked in
|
||||||
|
* `water_admin_sessions`.
|
||||||
|
*
|
||||||
|
* There is currently no per-admin-user PIN — a single brand-wide admin
|
||||||
|
* PIN. If you need per-user PINs (e.g. for an audit trail of which
|
||||||
|
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
|
||||||
|
* key sessions by `admin_user_id`.
|
||||||
|
*/
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
import {
|
||||||
|
waterAdminSettings,
|
||||||
|
waterAdminSessions,
|
||||||
|
type WaterAdminSettings,
|
||||||
|
} from "@/db/schema/water-log";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||||
|
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||||
|
|
||||||
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
|
export type AdminSettings = {
|
||||||
// `hash_water_admin_pin`, `save_water_admin_settings`,
|
|
||||||
// `verify_water_admin_pin`) and the underlying
|
|
||||||
// `water_admin_settings` table are not in the SaaS rebuild schema.
|
|
||||||
// The functions below preserve the original signatures and return
|
|
||||||
// empty / no-op responses. Same pattern as
|
|
||||||
// `actions/route-trace/lots.ts`.
|
|
||||||
|
|
||||||
export type WaterAdminSettings = {
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
session_duration_hours: number;
|
sessionDurationHours: number;
|
||||||
can_edit_entries: boolean;
|
canEditEntries: boolean;
|
||||||
can_delete_entries: boolean;
|
canDeleteEntries: boolean;
|
||||||
can_export_csv: boolean;
|
canExportCsv: boolean;
|
||||||
alert_phone?: string | null;
|
alertPhone: string | null;
|
||||||
alerts_enabled?: boolean;
|
alertsEnabled: boolean;
|
||||||
|
/** The currently configured PIN, in plain text. Only returned on
|
||||||
|
* read right after the admin regenerates it; otherwise null. */
|
||||||
|
pin: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
function mapSettings(s: WaterAdminSettings): AdminSettings {
|
||||||
|
return {
|
||||||
|
enabled: s.enabled,
|
||||||
|
sessionDurationHours: s.sessionDurationHours,
|
||||||
|
canEditEntries: s.canEditEntries,
|
||||||
|
canDeleteEntries: s.canDeleteEntries,
|
||||||
|
canExportCsv: s.canExportCsv,
|
||||||
|
alertPhone: s.alertPhone,
|
||||||
|
alertsEnabled: s.alertsEnabled,
|
||||||
|
pin: null, // never returned unless just regenerated
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
|
export async function getWaterAdminSettings(
|
||||||
return null;
|
brandId: string,
|
||||||
|
): Promise<AdminSettings | null> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
if (
|
||||||
|
!adminUser.can_manage_water_log &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterAdminSettings)
|
||||||
|
.where(eq(waterAdminSettings.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
if (!rows[0]) {
|
||||||
|
// Lazy-initialize default settings the first time the page is hit
|
||||||
|
const [created] = await db
|
||||||
|
.insert(waterAdminSettings)
|
||||||
|
.values({ brandId })
|
||||||
|
.returning();
|
||||||
|
if (!created) return null;
|
||||||
|
return { ...mapSettings(created), pin: null };
|
||||||
|
}
|
||||||
|
return mapSettings(rows[0]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWaterAdminSettings(
|
export async function saveWaterAdminSettings(
|
||||||
_brandId: string,
|
brandId: string,
|
||||||
_settings: Partial<WaterAdminSettings & { pin?: string }>
|
settings: Partial<AdminSettings>,
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
if (
|
||||||
return { success: false, error: NOT_CONFIGURED };
|
!adminUser.can_manage_water_log &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.sessionDurationHours != null &&
|
||||||
|
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Session duration must be 1–168 hours" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const update: Partial<WaterAdminSettings> = {};
|
||||||
|
if (settings.enabled != null) update.enabled = settings.enabled;
|
||||||
|
if (settings.sessionDurationHours != null)
|
||||||
|
update.sessionDurationHours = settings.sessionDurationHours;
|
||||||
|
if (settings.canEditEntries != null)
|
||||||
|
update.canEditEntries = settings.canEditEntries;
|
||||||
|
if (settings.canDeleteEntries != null)
|
||||||
|
update.canDeleteEntries = settings.canDeleteEntries;
|
||||||
|
if (settings.canExportCsv != null)
|
||||||
|
update.canExportCsv = settings.canExportCsv;
|
||||||
|
if (settings.alertPhone !== undefined)
|
||||||
|
update.alertPhone = settings.alertPhone;
|
||||||
|
if (settings.alertsEnabled != null)
|
||||||
|
update.alertsEnabled = settings.alertsEnabled;
|
||||||
|
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
|
||||||
|
|
||||||
|
// Upsert: insert if missing, update if present.
|
||||||
|
const updated = await db
|
||||||
|
.insert(waterAdminSettings)
|
||||||
|
.values({ brandId, ...update })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: waterAdminSettings.brandId,
|
||||||
|
set: update,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
brandId,
|
||||||
|
actorId: adminUser.user_id ?? null,
|
||||||
|
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
|
||||||
|
action: "update",
|
||||||
|
entityType: "settings",
|
||||||
|
details: { ...update },
|
||||||
|
});
|
||||||
|
return { success: true, settings: mapSettings(updated[0]) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyWaterAdminPin(
|
/**
|
||||||
_brandId: string,
|
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
|
||||||
_pin: string
|
* plaintext PIN *once* — caller is responsible for showing it to the
|
||||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
* admin and never persisting it.
|
||||||
return { success: false, error: NOT_CONFIGURED };
|
*/
|
||||||
|
export async function regenerateAdminPin(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (
|
||||||
|
!adminUser.can_manage_water_log &&
|
||||||
|
adminUser.role !== "platform_admin"
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Not authorized" };
|
||||||
|
}
|
||||||
|
const pin = generatePin();
|
||||||
|
const pinHash = hashPin(pin);
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
await db
|
||||||
|
.insert(waterAdminSettings)
|
||||||
|
.values({ brandId, pinHash })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: waterAdminSettings.brandId,
|
||||||
|
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
|
||||||
|
});
|
||||||
|
// Invalidate any existing admin sessions for safety
|
||||||
|
await db
|
||||||
|
.delete(waterAdminSessions)
|
||||||
|
.where(eq(waterAdminSessions.brandId, brandId));
|
||||||
|
await logAuditEvent({
|
||||||
|
brandId,
|
||||||
|
actorId: adminUser.user_id ?? null,
|
||||||
|
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
|
||||||
|
action: "regenerate_admin_pin",
|
||||||
|
entityType: "settings",
|
||||||
|
});
|
||||||
|
return { success: true, pin };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PIN verify for the `/water/admin` portal. Creates a session row and
|
||||||
|
* sets the `wl_admin_session` cookie.
|
||||||
|
*/
|
||||||
|
export async function verifyWaterAdminPin(
|
||||||
|
brandId: string,
|
||||||
|
pin: string,
|
||||||
|
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||||
|
const formatError = validatePin(pin);
|
||||||
|
if (formatError) return { success: false, error: formatError };
|
||||||
|
|
||||||
|
return withBrand(brandId, async (db) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(waterAdminSettings)
|
||||||
|
.where(eq(waterAdminSettings.brandId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const settings = rows[0];
|
||||||
|
if (!settings) {
|
||||||
|
return { success: false, error: "Admin portal not configured" };
|
||||||
|
}
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return { success: false, error: "Admin portal is disabled" };
|
||||||
|
}
|
||||||
|
if (!settings.pinHash) {
|
||||||
|
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
|
||||||
|
}
|
||||||
|
if (!verifyPin(pin, settings.pinHash)) {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
return { success: false, error: "Invalid PIN" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tie the session to the calling site admin (best-effort).
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
const [session] = await db
|
||||||
|
.insert(waterAdminSessions)
|
||||||
|
.values({
|
||||||
|
brandId,
|
||||||
|
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||||
|
pinHashUsed: settings.pinHash,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({ id: waterAdminSessions.id });
|
||||||
|
if (!session) return { success: false, error: "Failed to create session" };
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set("wl_admin_session", session.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: settings.sessionDurationHours * 3600,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, session_id: session.id };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/water-log/settings
|
||||||
|
*
|
||||||
|
* Site-admin-facing settings page for the Water Log module. Controls:
|
||||||
|
* - Whether `/water/admin` is enabled
|
||||||
|
* - 4-digit admin PIN (regenerate on demand)
|
||||||
|
* - Session duration (hours)
|
||||||
|
* - Per-coarse-permission flags (edit / delete / export)
|
||||||
|
* - High/low alert phone + enable flag
|
||||||
|
*
|
||||||
|
* Visual language: Field Almanac. Same cream + forest palette as
|
||||||
|
* the main WaterLogAdminPanel, but trimmed to a single column for
|
||||||
|
* a focused "form" feel.
|
||||||
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
|
import {
|
||||||
import { useRouter } from "next/navigation";
|
getWaterAdminSettings,
|
||||||
|
saveWaterAdminSettings,
|
||||||
|
regenerateAdminPin,
|
||||||
|
type AdminSettings,
|
||||||
|
} from "@/actions/water-log/settings";
|
||||||
|
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
export default function WaterLogSettingsPage() {
|
export default function WaterLogSettingsPage() {
|
||||||
const router = useRouter();
|
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||||
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [regenerating, setRegenerating] = useState(false);
|
||||||
|
const [revealedPin, setRevealedPin] = useState<string | null>(null);
|
||||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
const [pin, setPin] = useState("");
|
|
||||||
const [confirmPin, setConfirmPin] = useState("");
|
|
||||||
const [sessionDuration, setSessionDuration] = useState(12);
|
const [sessionDuration, setSessionDuration] = useState(12);
|
||||||
const [canEdit, setCanEdit] = useState(true);
|
const [canEdit, setCanEdit] = useState(true);
|
||||||
const [canDelete, setCanDelete] = useState(false);
|
const [canDelete, setCanDelete] = useState(true);
|
||||||
const [canExport, setCanExport] = useState(true);
|
const [canExport, setCanExport] = useState(true);
|
||||||
const [alertPhone, setAlertPhone] = useState("");
|
const [alertPhone, setAlertPhone] = useState("");
|
||||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||||
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
|
|||||||
if (data) {
|
if (data) {
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
setEnabled(data.enabled);
|
setEnabled(data.enabled);
|
||||||
setSessionDuration(data.session_duration_hours);
|
setSessionDuration(data.sessionDurationHours);
|
||||||
setCanEdit(data.can_edit_entries);
|
setCanEdit(data.canEditEntries);
|
||||||
setCanDelete(data.can_delete_entries);
|
setCanDelete(data.canDeleteEntries);
|
||||||
setCanExport(data.can_export_csv);
|
setCanExport(data.canExportCsv);
|
||||||
setAlertPhone(data.alert_phone ?? "");
|
setAlertPhone(data.alertPhone ?? "");
|
||||||
setAlertsEnabled(data.alerts_enabled ?? false);
|
setAlertsEnabled(data.alertsEnabled);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
// Load on mount — setState-in-effect is intentional here
|
||||||
await loadSettings();
|
// because we're hydrating from server data.
|
||||||
};
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
init();
|
void loadSettings();
|
||||||
}, [loadSettings]);
|
}, [loadSettings]);
|
||||||
|
|
||||||
async function handleSave(e: React.FormEvent) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (enabled && pin && pin !== confirmPin) {
|
|
||||||
setMessage({ type: "error", text: "PINs do not match" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
|
|
||||||
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||||||
enabled,
|
enabled,
|
||||||
pin: pin || undefined,
|
sessionDurationHours: sessionDuration,
|
||||||
session_duration_hours: sessionDuration,
|
canEditEntries: canEdit,
|
||||||
can_edit_entries: canEdit,
|
canDeleteEntries: canDelete,
|
||||||
can_delete_entries: canDelete,
|
canExportCsv: canExport,
|
||||||
can_export_csv: canExport,
|
alertPhone: alertPhone || null,
|
||||||
alert_phone: alertPhone || null,
|
alertsEnabled,
|
||||||
alerts_enabled: alertsEnabled,
|
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setMessage({ type: "success", text: "Settings saved" });
|
setMessage({ type: "success", text: "Settings saved" });
|
||||||
setPin("");
|
if (result.settings) setSettings(result.settings);
|
||||||
setConfirmPin("");
|
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
||||||
}
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate() {
|
||||||
|
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRegenerating(true);
|
||||||
|
setMessage(null);
|
||||||
|
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
|
||||||
|
if (result.success && result.pin) {
|
||||||
|
setRevealedPin(result.pin);
|
||||||
|
setMessage({
|
||||||
|
type: "success",
|
||||||
|
text: "New PIN generated. Save it now — it will not be shown again.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
|
||||||
|
}
|
||||||
|
setRegenerating(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
|
||||||
<span className="text-zinc-500">Loading...</span>
|
<span className="text-[#5a5d5a]">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950">
|
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
<div className="border-b border-[#d4d9d3] bg-white">
|
||||||
<div className="mx-auto max-w-lg">
|
<div className="mx-auto max-w-2xl px-6 py-6">
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 — Settings</p>
|
||||||
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm">← Back</button>
|
<h1
|
||||||
<div>
|
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
|
||||||
<h1 className="text-xl font-bold text-zinc-100">Water Log — Admin Portal</h1>
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
|
>
|
||||||
</div>
|
Water Log · Admin Portal
|
||||||
</div>
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#5a5d5a]">
|
||||||
|
Configure PIN access for the field admin portal at <code className="rounded bg-[#f4f1e8] px-1 py-0.5 font-mono text-xs">/water/admin</code>.
|
||||||
|
This is separate from the platform admin login.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-lg px-6 py-6">
|
<div className="mx-auto max-w-2xl px-6 py-8">
|
||||||
<form onSubmit={handleSave} className="space-y-6">
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
|
<div
|
||||||
|
className={`rounded-lg border px-4 py-3 text-sm font-medium ${
|
||||||
|
message.type === "success"
|
||||||
|
? "border-[#1a4d2e] bg-[#1a4d2e]/5 text-[#1a4d2e]"
|
||||||
|
: "border-[#a4452b] bg-[#a4452b]/5 text-[#a4452b]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{message.text}
|
{message.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Enable toggle */}
|
{/* Enable toggle */}
|
||||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
|
||||||
<div className="flex items-center justify-between">
|
<ToggleRow
|
||||||
<div>
|
label="Enable"
|
||||||
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
|
description="Allow water admins to sign in with a 4-digit PIN."
|
||||||
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
|
value={enabled}
|
||||||
</div>
|
onChange={setEnabled}
|
||||||
<button
|
/>
|
||||||
type="button"
|
</Card>
|
||||||
onClick={() => setEnabled((v) => !v)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{enabled && (
|
{enabled && (
|
||||||
<>
|
<>
|
||||||
{/* PIN */}
|
{/* PIN */}
|
||||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
|
||||||
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
|
{revealedPin ? (
|
||||||
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
|
||||||
<div>
|
New PIN — write it down
|
||||||
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
|
</p>
|
||||||
<input
|
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
|
||||||
id="water-new-pin"
|
<span
|
||||||
type="password"
|
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
|
||||||
inputMode="numeric"
|
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
|
||||||
pattern="[0-9]*"
|
>
|
||||||
maxLength={4}
|
{revealedPin}
|
||||||
value={pin}
|
</span>
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
|
||||||
placeholder="••••"
|
|
||||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
|
||||||
style={{ letterSpacing: "0.4em" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
|
|
||||||
<input
|
|
||||||
id="water-confirm-pin"
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={4}
|
|
||||||
value={confirmPin}
|
|
||||||
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
|
||||||
placeholder="••••"
|
|
||||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
|
||||||
style={{ letterSpacing: "0.4em" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session Duration */}
|
|
||||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
|
||||||
<p className="font-semibold text-zinc-100">Session Duration</p>
|
|
||||||
<p className="text-xs text-zinc-500">How long the admin session lasts after login (1–72 hours).</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={72}
|
|
||||||
value={sessionDuration}
|
|
||||||
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
|
||||||
<p className="font-semibold text-zinc-100">Permissions</p>
|
|
||||||
{[
|
|
||||||
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
|
|
||||||
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
|
|
||||||
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
|
|
||||||
].map(({ key, label, desc, value, setter }) => (
|
|
||||||
<div key={key} className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
|
||||||
<p className="text-xs text-zinc-500">{desc}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setter((v: boolean) => !v)}
|
onClick={() => setRevealedPin(null)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
|
className="text-xs font-medium text-[#5a5d5a] underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
|
I've saved it — dismiss
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-[#5a5d5a]">
|
||||||
|
{settings?.pin === null
|
||||||
|
? "Generate a new PIN to give to your water admin."
|
||||||
|
: "A PIN is already configured. Regenerate to issue a new one (this signs out existing admin sessions)."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
disabled={regenerating}
|
||||||
|
className="shrink-0 rounded-lg border border-[#1a4d2e] bg-[#1a4d2e] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{regenerating ? "Generating…" : "Regenerate PIN"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Session Duration */}
|
||||||
|
<Card title="Session Duration" subtitle="How long the admin session lasts after sign-in (1–168 hours)">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
value={sessionDuration}
|
||||||
|
onChange={(e) => setSessionDuration(parseInt(e.target.value, 10))}
|
||||||
|
className="flex-1 accent-[#1a4d2e]"
|
||||||
|
aria-label="Session duration in hours"
|
||||||
|
/>
|
||||||
|
<span className="w-20 rounded border border-[#d4d9d3] bg-white px-3 py-1.5 text-center font-mono text-sm font-semibold">
|
||||||
|
{sessionDuration}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Permissions */}
|
||||||
|
<Card title="Permissions" subtitle="Coarse-grained flags. The PIN sign-in gates the portal; these flags gate specific actions.">
|
||||||
|
<div className="divide-y divide-[#d4d9d3]">
|
||||||
|
<ToggleRow
|
||||||
|
label="Edit entries"
|
||||||
|
description="Allow modifying existing log entries."
|
||||||
|
value={canEdit}
|
||||||
|
onChange={setCanEdit}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Delete entries"
|
||||||
|
description="Allow removing log entries."
|
||||||
|
value={canDelete}
|
||||||
|
onChange={setCanDelete}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="Export CSV"
|
||||||
|
description="Allow exporting water log data."
|
||||||
|
value={canExport}
|
||||||
|
onChange={setCanExport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* High/Low Alerts */}
|
{/* High/Low Alerts */}
|
||||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
|
||||||
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
|
<ToggleRow
|
||||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
label="Enable alerts"
|
||||||
|
description="Threshold breach notifications."
|
||||||
<div className="flex items-center justify-between">
|
value={alertsEnabled}
|
||||||
<div>
|
onChange={setAlertsEnabled}
|
||||||
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
|
/>
|
||||||
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAlertsEnabled((v) => !v)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
|
|
||||||
>
|
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{alertsEnabled && (
|
{alertsEnabled && (
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
|
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
|
||||||
|
Alert phone number
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="water-alert-phone"
|
id="water-alert-phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={alertPhone}
|
value={alertPhone}
|
||||||
onChange={(e) => setAlertPhone(e.target.value)}
|
onChange={(e) => setAlertPhone(e.target.value)}
|
||||||
placeholder="+1234567890"
|
placeholder="+13035551234"
|
||||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
|
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
|
<p className="mt-1 text-xs text-[#8a8b88]">
|
||||||
|
U.S. format recommended. Include country code (e.g. <span className="font-mono">+1</span> for USA).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* QR hint */}
|
|
||||||
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
|
|
||||||
<p className="text-xs text-amber-700">
|
|
||||||
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
|
className="w-full rounded-lg bg-[#1a4d2e] px-6 py-3.5 text-sm font-semibold uppercase tracking-wider text-white transition hover:bg-[#143d24] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save Settings"}
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-[#d4d9d3] bg-white p-5 shadow-sm">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h2 className="text-base font-semibold text-[#1d1d1f]">{title}</h2>
|
||||||
|
{subtitle && <p className="mt-0.5 text-xs text-[#5a5d5a]">{subtitle}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-[#1d1d1f]">{label}</p>
|
||||||
|
{description && <p className="mt-0.5 text-xs text-[#5a5d5a]">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={value}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||||
|
value ? "bg-[#1a4d2e]" : "bg-[#d4d9d3]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/water-admin-auth
|
||||||
|
*
|
||||||
|
* Body: { brandId: string, pin: string }
|
||||||
|
* Side effect: sets the `wl_admin_session` cookie on success.
|
||||||
|
*
|
||||||
|
* Used by the mobile/PIN-only `/water/admin/login` portal. The session
|
||||||
|
* itself is created by `verifyWaterAdminPin()` in
|
||||||
|
* `src/actions/water-log/settings.ts`, which is the single source of
|
||||||
|
* truth for admin PIN verification.
|
||||||
|
*/
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
|
||||||
import { pool } from "@/lib/db";
|
import { captureError } from "@/lib/sentry";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { brandId, pin } = await request.json();
|
const { brandId, pin } = (await request.json()) as {
|
||||||
|
brandId?: string;
|
||||||
|
pin?: string;
|
||||||
|
};
|
||||||
if (!brandId || !pin) {
|
if (!brandId || !pin) {
|
||||||
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Missing brandId or pin" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const result = await verifyWaterAdminPin(brandId, pin);
|
||||||
// Get admin settings
|
if (!result.success) {
|
||||||
const settingsRes = await pool.query<{
|
// Don't leak whether the PIN was wrong vs. not configured — both
|
||||||
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
|
// are the same to a field attacker probing the endpoint.
|
||||||
}>(
|
const status = result.error === "Invalid PIN" ? 401 : 403;
|
||||||
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
|
return NextResponse.json(
|
||||||
[brandId],
|
{ success: false, error: "Invalid PIN" },
|
||||||
);
|
{ status },
|
||||||
const settings = settingsRes.rows[0]?.get_water_admin_settings;
|
);
|
||||||
if (!settings?.enabled) {
|
|
||||||
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PIN
|
|
||||||
const verifyRes = await pool.query<{
|
|
||||||
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
|
|
||||||
}>(
|
|
||||||
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
|
|
||||||
[brandId, pin],
|
|
||||||
);
|
|
||||||
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
|
|
||||||
if (!verifyData?.success || !verifyData.session_id) {
|
|
||||||
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session cookie
|
|
||||||
const sessionId = verifyData.session_id;
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const durationHours = settings.session_duration_hours ?? 4;
|
|
||||||
cookieStore.set("wl_admin_session", sessionId, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: durationHours * 3600,
|
|
||||||
path: "/",
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch {
|
} catch (err) {
|
||||||
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
|
captureError(err as Error, { path: "/api/water-admin-auth" });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Server error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/water-logs/export
|
||||||
|
*
|
||||||
|
* Streams the water log as JSON or CSV. Admin-only — checked via
|
||||||
|
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
|
||||||
|
* session, with Tuxedo fallback for backward compat.
|
||||||
|
*
|
||||||
|
* GET /api/water-logs/export?format=csv
|
||||||
|
*/
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { pool } from "@/lib/db";
|
import { getWaterEntries } from "@/actions/water-log/admin";
|
||||||
|
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) {
|
if (!adminUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!adminUser.can_manage_water_log) {
|
if (!adminUser.can_manage_water_log) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const format = searchParams.get("format") ?? "json";
|
const format = searchParams.get("format") ?? "json";
|
||||||
|
const brandId =
|
||||||
|
adminUser.brand_id ??
|
||||||
|
process.env.TUXEDO_BRAND_ID ??
|
||||||
|
TUXEDO_BRAND_ID;
|
||||||
|
|
||||||
// Use brand_id from session (always Tuxedo for water log) or fallback to env
|
const raw = await getWaterEntries(brandId, 10000);
|
||||||
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const rows: WaterLogReportRow[] = raw.map((r) => ({
|
||||||
|
logged_at: r.logged_at,
|
||||||
type WaterEntry = {
|
headgate_name: r.headgate_name,
|
||||||
id: string;
|
user_name: r.user_name,
|
||||||
user_id: string | null;
|
user_role: "irrigator",
|
||||||
headgate_id: string | null;
|
measurement: r.measurement,
|
||||||
measurement: number | null;
|
unit: r.unit,
|
||||||
unit: string | null;
|
notes: r.notes,
|
||||||
notes: string | null;
|
submitted_via: r.submitted_via,
|
||||||
created_at: string;
|
}));
|
||||||
};
|
|
||||||
|
|
||||||
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
|
|
||||||
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
|
|
||||||
[brandId, 10000],
|
|
||||||
);
|
|
||||||
const entries = data[0]?.get_water_entries ?? [];
|
|
||||||
|
|
||||||
if (format === "csv") {
|
if (format === "csv") {
|
||||||
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
|
const headers = [
|
||||||
const csvRows = [headers.join(",")];
|
"When",
|
||||||
for (const row of entries) {
|
"Headgate",
|
||||||
csvRows.push([
|
"User",
|
||||||
row.id,
|
"Measurement",
|
||||||
row.user_id ?? "",
|
"Unit",
|
||||||
row.headgate_id ?? "",
|
"Total Gallons",
|
||||||
row.measurement ?? "",
|
"Method",
|
||||||
row.unit ?? "",
|
"Notes",
|
||||||
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
|
"Via",
|
||||||
row.created_at ?? "",
|
"Photo URL",
|
||||||
].join(","));
|
];
|
||||||
|
const esc = (s: string | null | undefined) =>
|
||||||
|
`"${(s ?? "").replace(/"/g, '""')}"`;
|
||||||
|
const lines: string[] = [headers.join(",")];
|
||||||
|
for (const e of raw) {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
esc(e.logged_at),
|
||||||
|
esc(e.headgate_name),
|
||||||
|
esc(e.user_name),
|
||||||
|
String(e.measurement),
|
||||||
|
esc(e.unit),
|
||||||
|
e.total_gallons != null ? String(e.total_gallons) : "",
|
||||||
|
esc(e.method),
|
||||||
|
esc(e.notes),
|
||||||
|
esc(e.submitted_via),
|
||||||
|
esc(e.photo_url),
|
||||||
|
].join(","),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new NextResponse(csvRows.join("\n"), {
|
return new NextResponse(lines.join("\n"), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/csv",
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
|
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(entries);
|
return NextResponse.json({ rows });
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -361,8 +361,8 @@ export default function WaterAdminClient() {
|
|||||||
setSavingUser(true);
|
setSavingUser(true);
|
||||||
setNewUserError(null);
|
setNewUserError(null);
|
||||||
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
|
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
|
||||||
if (result.success && result.pin) {
|
if (result.success && result.pin && result.user) {
|
||||||
setNewPin({ name: result.irrigator!.name, pin: result.pin });
|
setNewPin({ name: result.user.name, pin: result.pin });
|
||||||
setNewUserName("");
|
setNewUserName("");
|
||||||
setShowAddUser(false);
|
setShowAddUser(false);
|
||||||
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
|
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setWaterLang,
|
setWaterLang,
|
||||||
} from "@/actions/water-log/field";
|
} from "@/actions/water-log/field";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { WaterGauge } from "@/components/water/icons";
|
||||||
|
|
||||||
type Headgate = {
|
type Headgate = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -345,10 +346,13 @@ function WaterFieldInner() {
|
|||||||
// Language selection screen
|
// Language selection screen
|
||||||
if (step === "loading") {
|
if (step === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-xs text-center">
|
<div className="w-full max-w-xs text-center">
|
||||||
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
|
<div className="mx-auto mb-4 flex justify-center">
|
||||||
<p className="text-stone-500 text-base">Loading...</p>
|
<WaterGauge size={64} level={null} status="open" />
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-[#57694e] text-base font-medium">Loading…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -357,20 +361,31 @@ function WaterFieldInner() {
|
|||||||
// Language selection screen
|
// Language selection screen
|
||||||
if (step === "lang") {
|
if (step === "lang") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
<div className="mb-3 flex justify-center">
|
||||||
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p>
|
<WaterGauge size={56} level={null} status="open" />
|
||||||
<div className="flex flex-col gap-4">
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
|
||||||
|
Tuxedo Ditch Co.
|
||||||
|
</p>
|
||||||
|
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLangSelect("en")}
|
onClick={() => handleLangSelect("en")}
|
||||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||||
>
|
>
|
||||||
English
|
English
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLangSelect("es")}
|
onClick={() => handleLangSelect("es")}
|
||||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||||
>
|
>
|
||||||
Español
|
Español
|
||||||
</button>
|
</button>
|
||||||
@@ -383,26 +398,31 @@ function WaterFieldInner() {
|
|||||||
// Role selection screen
|
// Role selection screen
|
||||||
if (step === "role") {
|
if (step === "role") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep("lang")}
|
onClick={() => setStep("lang")}
|
||||||
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
|
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
|
||||||
>
|
>
|
||||||
← {t.back}
|
← {t.back}
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
<h1
|
||||||
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p>
|
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||||
<div className="flex flex-col gap-4">
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep("pin"); }}
|
onClick={() => { setStep("pin"); }}
|
||||||
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-stone-900 shadow-sm ring-1 ring-stone-200 hover:bg-stone-50 min-h-[64px]"
|
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
|
||||||
>
|
>
|
||||||
Irrigator
|
Irrigator
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep("pin"); }}
|
onClick={() => { setStep("pin"); }}
|
||||||
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-stone-700 hover:bg-stone-800 min-h-[64px]"
|
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</button>
|
</button>
|
||||||
@@ -415,16 +435,21 @@ function WaterFieldInner() {
|
|||||||
// PIN entry screen
|
// PIN entry screen
|
||||||
if (step === "pin") {
|
if (step === "pin") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-xs">
|
<div className="w-full max-w-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep("role")}
|
onClick={() => setStep("role")}
|
||||||
className="text-sm text-stone-500 mb-6 hover:text-stone-700"
|
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
|
||||||
>
|
>
|
||||||
← {t.back}
|
← {t.back}
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
<h1
|
||||||
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p>
|
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
|
||||||
|
|
||||||
<form onSubmit={handlePinSubmit}>
|
<form onSubmit={handlePinSubmit}>
|
||||||
<input
|
<input
|
||||||
@@ -435,17 +460,17 @@ function WaterFieldInner() {
|
|||||||
value={pin}
|
value={pin}
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||||
placeholder={t.pinPlaceholder}
|
placeholder={t.pinPlaceholder}
|
||||||
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4"
|
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
|
||||||
style={{ letterSpacing: "0.3em" }}
|
style={{ letterSpacing: "0.3em" }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-center text-red-600 text-sm mb-4">{error}</p>
|
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pin.length !== 4 || loading}
|
disabled={pin.length !== 4 || loading}
|
||||||
className="w-full rounded-xl bg-stone-900 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 min-h-[64px]"
|
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
|
||||||
>
|
>
|
||||||
{loading ? t.loggingIn : t.submit}
|
{loading ? t.loggingIn : t.submit}
|
||||||
</button>
|
</button>
|
||||||
@@ -459,17 +484,27 @@ function WaterFieldInner() {
|
|||||||
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
|
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50">
|
<div className="min-h-screen bg-[#fdfaf2]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-stone-900 px-4 py-4">
|
<div className="bg-[#1a4d2e] px-4 py-4">
|
||||||
<div className="mx-auto max-w-lg flex items-center justify-between">
|
<div className="mx-auto max-w-lg flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold text-white">{t.title}</h1>
|
<WaterGauge size={32} level={null} status="open" />
|
||||||
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p>
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-bold text-white"
|
||||||
|
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-[#bbf7d0] font-mono uppercase tracking-wider">
|
||||||
|
{t.loggedInAs}: {irrigatorName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]"
|
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
|
||||||
>
|
>
|
||||||
{t.logout}
|
{t.logout}
|
||||||
</button>
|
</button>
|
||||||
@@ -477,7 +512,7 @@ function WaterFieldInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step progress indicator */}
|
{/* Step progress indicator */}
|
||||||
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2">
|
<div className="bg-[#fafaf7] border-b border-[#d4d9d3] px-4 py-2">
|
||||||
<div className="mx-auto max-w-lg">
|
<div className="mx-auto max-w-lg">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{["lang", "role", "pin", "form"].map((s, i) => {
|
{["lang", "role", "pin", "form"].map((s, i) => {
|
||||||
@@ -486,8 +521,8 @@ function WaterFieldInner() {
|
|||||||
const isPast = stepIndex > i;
|
const isPast = stepIndex > i;
|
||||||
return (
|
return (
|
||||||
<div key={s} className="flex items-center gap-1.5">
|
<div key={s} className="flex items-center gap-1.5">
|
||||||
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} />
|
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-[#1a4d2e]" : isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />
|
||||||
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />}
|
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* SeasonMark — small inline sun / snowflake indicator used in the
|
||||||
|
* "Today's Summary" card to signal irrigation season status. Drawn as
|
||||||
|
* a flat 16px mark; uses the project's existing color tokens so it
|
||||||
|
* works in light and dark contexts.
|
||||||
|
*/
|
||||||
|
type Props = {
|
||||||
|
inSeason: boolean;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SeasonMark({
|
||||||
|
inSeason,
|
||||||
|
size = 16,
|
||||||
|
className = "",
|
||||||
|
title,
|
||||||
|
}: Props) {
|
||||||
|
const stroke = "#1a4d2e";
|
||||||
|
if (inSeason) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label={title ?? "In irrigation season"}
|
||||||
|
style={{ display: "inline-block", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="2.8" fill="#facc15" stroke={stroke} strokeWidth="1" />
|
||||||
|
{[
|
||||||
|
[8, 1],
|
||||||
|
[8, 15],
|
||||||
|
[1, 8],
|
||||||
|
[15, 8],
|
||||||
|
[3, 3],
|
||||||
|
[13, 13],
|
||||||
|
[3, 13],
|
||||||
|
[13, 3],
|
||||||
|
].map(([cx, cy], i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={8}
|
||||||
|
y1={8}
|
||||||
|
x2={cx}
|
||||||
|
y2={cy}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label={title ?? "Outside irrigation season"}
|
||||||
|
style={{ display: "inline-block", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<g stroke="#1e3a8a" strokeWidth="1.2" strokeLinecap="round" fill="none">
|
||||||
|
<line x1="8" y1="1" x2="8" y2="15" />
|
||||||
|
<line x1="1" y1="8" x2="15" y2="8" />
|
||||||
|
<line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
|
||||||
|
<line x1="2.5" y1="13.5" x2="13.5" y2="2.5" />
|
||||||
|
{/* Arrow heads */}
|
||||||
|
<polyline points="6.5,2.5 8,1 9.5,2.5" />
|
||||||
|
<polyline points="6.5,13.5 8,15 9.5,13.5" />
|
||||||
|
<polyline points="2.5,6.5 1,8 2.5,9.5" />
|
||||||
|
<polyline points="13.5,6.5 15,8 13.5,9.5" />
|
||||||
|
<polyline points="4,4 2.5,2.5 4,1" />
|
||||||
|
<polyline points="12,4 13.5,2.5 12,1" />
|
||||||
|
<polyline points="4,12 2.5,13.5 4,15" />
|
||||||
|
<polyline points="12,12 13.5,13.5 12,15" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* WaterGauge — a stylized "ditch gauge" SVG used as a visual signature
|
||||||
|
* for the Water Log feature. Renders a vertical pipe with a water level
|
||||||
|
* and a small headgate at the top. Deliberately drawn with a slight
|
||||||
|
* hand-sketched quality (rough strokes, no perfectly straight lines)
|
||||||
|
* to evoke a field-notebook annotation rather than a slick dashboard
|
||||||
|
* chart.
|
||||||
|
*
|
||||||
|
* <WaterGauge size={48} level={0.7} status="open" />
|
||||||
|
* <WaterGauge size={24} level={null} status="closed" />
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number;
|
||||||
|
level?: number | null; // 0..1
|
||||||
|
status?: "open" | "closed" | "maintenance";
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WaterGauge({
|
||||||
|
size = 32,
|
||||||
|
level = 0.5,
|
||||||
|
status = "open",
|
||||||
|
className = "",
|
||||||
|
ariaLabel = "Water gauge",
|
||||||
|
}: Props) {
|
||||||
|
const w = size;
|
||||||
|
const h = size * 1.25;
|
||||||
|
const stroke = Math.max(1, size / 16);
|
||||||
|
// Clamp + choose fill level
|
||||||
|
const l =
|
||||||
|
level == null
|
||||||
|
? status === "closed"
|
||||||
|
? 0
|
||||||
|
: 0.45
|
||||||
|
: Math.max(0, Math.min(1, level));
|
||||||
|
|
||||||
|
// Color reflects the operational state
|
||||||
|
const waterColor =
|
||||||
|
status === "maintenance"
|
||||||
|
? "#a16207" // gold-700
|
||||||
|
: status === "closed"
|
||||||
|
? "#86868b" // surface-400
|
||||||
|
: l > 0.85
|
||||||
|
? "#dc2626" // red — over threshold
|
||||||
|
: l < 0.15
|
||||||
|
? "#2563eb" // blue — under threshold
|
||||||
|
: "#0e7490"; // cyan-700 — flowing nicely
|
||||||
|
const headgateColor = status === "closed" ? "#3b3b3f" : "#1a4d2e";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={w}
|
||||||
|
height={h}
|
||||||
|
viewBox="0 0 32 40"
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={className}
|
||||||
|
style={{ display: "inline-block", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{/* Top "headgate" — a small handled gate */}
|
||||||
|
<g stroke={headgateColor} strokeWidth={stroke} fill="none" strokeLinecap="round">
|
||||||
|
{/* Gate top bar */}
|
||||||
|
<path d="M9 4 H23" />
|
||||||
|
{/* Gate handle */}
|
||||||
|
<path d="M14 1.5 V4 M18 1.5 V4" />
|
||||||
|
{/* Gate frame */}
|
||||||
|
<path d="M8 4 V8 H24 V4" />
|
||||||
|
{/* Gate wheel */}
|
||||||
|
<circle cx="16" cy="3" r="1.2" fill={headgateColor} />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* The ditch / pipe */}
|
||||||
|
<g
|
||||||
|
stroke="#1a1a1a"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M8 8 L8 36 L24 36 L24 8" />
|
||||||
|
{/* Tick marks at quarter / half / three-quarter */}
|
||||||
|
<path d="M8 17 H11 M8 24 H11 M8 31 H11" />
|
||||||
|
<path d="M24 17 H21 M24 24 H21 M24 31 H21" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Water level — clipped inside the pipe */}
|
||||||
|
<clipPath id={`wg-clip-${Math.round(size)}-${status}`}>
|
||||||
|
<path d="M9.2 9 L9.2 35.2 L22.8 35.2 L22.8 9 Z" />
|
||||||
|
</clipPath>
|
||||||
|
<g clipPath={`url(#wg-clip-${Math.round(size)}-${status})`}>
|
||||||
|
<rect
|
||||||
|
x={9.2}
|
||||||
|
y={35.2 - 25.6 * l}
|
||||||
|
width={13.6}
|
||||||
|
height={25.6 * l}
|
||||||
|
fill={waterColor}
|
||||||
|
opacity={0.85}
|
||||||
|
/>
|
||||||
|
{/* Wavy water surface — three short arcs */}
|
||||||
|
{l > 0 && (
|
||||||
|
<path
|
||||||
|
d={`M9.2 ${35.2 - 25.6 * l} q1.7 -1 3.4 0 t3.4 0 t3.4 0 t3.4 0`}
|
||||||
|
stroke={waterColor}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Status dot — top-right corner */}
|
||||||
|
<circle
|
||||||
|
cx={26}
|
||||||
|
cy={3}
|
||||||
|
r={1.4}
|
||||||
|
fill={
|
||||||
|
status === "open"
|
||||||
|
? "#16a34a" // green
|
||||||
|
: status === "closed"
|
||||||
|
? "#9ca3af" // gray
|
||||||
|
: "#ca8a04" // gold
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { WaterGauge } from "./WaterGauge";
|
||||||
|
export { SeasonMark } from "./SeasonMark";
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Water Log — small audit + alert helpers.
|
||||||
|
*
|
||||||
|
* Every mutating action in `actions/water-log/*` calls `logAuditEvent()`
|
||||||
|
* so a regulator or curious admin can answer "who deleted that headgate
|
||||||
|
* on Tuesday" without trawling server logs.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
import { query } from "@/lib/db";
|
||||||
|
import { withBrand } from "@/db/client";
|
||||||
|
|
||||||
|
export type WaterAuditEvent = {
|
||||||
|
brandId: string;
|
||||||
|
actorLabel: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: "headgate" | "user" | "entry" | "settings" | "session";
|
||||||
|
entityId?: string | null;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget audit insert. The withBrand wrapper sets the brand
|
||||||
|
* context so RLS lets the write through. We swallow errors so a broken
|
||||||
|
* audit log never blocks the user-facing action — but we always log to
|
||||||
|
* the console as a backstop.
|
||||||
|
*/
|
||||||
|
export async function logAuditEvent(ev: WaterAuditEvent): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withBrand(ev.brandId, async () => {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO water_audit_log
|
||||||
|
(brand_id, actor_id, actor_label, action, entity_type, entity_id, details)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
ev.brandId,
|
||||||
|
ev.actorId ?? null,
|
||||||
|
ev.actorLabel,
|
||||||
|
ev.action,
|
||||||
|
ev.entityType,
|
||||||
|
ev.entityId ?? null,
|
||||||
|
ev.details ? JSON.stringify(ev.details) : null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Audit must not break the user flow. Surface to console.
|
||||||
|
console.error("[water-log] audit insert failed", err, ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a high/low water alert. Currently this is a passive record — the
|
||||||
|
* scheduled job that actually sends SMS/WhatsApp can pick rows where
|
||||||
|
* `sent_at IS NULL`. Phase 2 wires up the sender.
|
||||||
|
*/
|
||||||
|
export async function logAlert(args: {
|
||||||
|
brandId: string;
|
||||||
|
headgateId: string | null;
|
||||||
|
alertType: "high" | "low";
|
||||||
|
reading: number;
|
||||||
|
threshold: number;
|
||||||
|
headgateName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withBrand(args.brandId, async () => {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO water_alert_log
|
||||||
|
(brand_id, alert_type, headgate_id, message)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[
|
||||||
|
args.brandId,
|
||||||
|
args.alertType,
|
||||||
|
args.headgateId,
|
||||||
|
`${args.alertType.toUpperCase()} alert on ${args.headgateName}: reading ${args.reading} crossed threshold ${args.threshold}`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[water-log] alert insert failed", err, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `src/lib/water-log-pin.ts` — PIN hashing/verification used
|
||||||
|
* by the field portal. The hashing layer is critical: a 4-digit PIN is
|
||||||
|
* essentially a 10,000-entry dictionary, so a slow KDF and a per-row
|
||||||
|
* random salt are the only defense-in-depth that matters if the
|
||||||
|
* database leaks.
|
||||||
|
*
|
||||||
|
* The `server-only` import is stubbed so the test runner can import
|
||||||
|
* the module (the pattern matches `tests/unit/passwords.test.ts`).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
hashPin,
|
||||||
|
verifyPin,
|
||||||
|
validatePin,
|
||||||
|
isWeakPin,
|
||||||
|
generatePin,
|
||||||
|
PIN_MIN,
|
||||||
|
PIN_MAX,
|
||||||
|
} from "@/lib/water-log-pin";
|
||||||
|
|
||||||
|
describe("validatePin", () => {
|
||||||
|
it("accepts a 4-digit numeric PIN", () => {
|
||||||
|
expect(validatePin("1234")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the full 8-digit range", () => {
|
||||||
|
expect(validatePin("12345678")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects pins shorter than PIN_MIN", () => {
|
||||||
|
expect(validatePin("12")).toMatch(/4.*8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects pins longer than PIN_MAX", () => {
|
||||||
|
expect(validatePin("123456789")).toMatch(/4.*8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-numeric input", () => {
|
||||||
|
expect(validatePin("123a")).toBe("PIN must be numeric");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string types", () => {
|
||||||
|
expect(validatePin(1234)).toBe("PIN must be a string");
|
||||||
|
expect(validatePin(null)).toBe("PIN must be a string");
|
||||||
|
expect(validatePin(undefined)).toBe("PIN must be a string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts leading zeros", () => {
|
||||||
|
expect(validatePin("0007")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isWeakPin", () => {
|
||||||
|
it("flags repeated digits", () => {
|
||||||
|
expect(isWeakPin("1111")).toBe(true);
|
||||||
|
expect(isWeakPin("0000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags monotonic runs", () => {
|
||||||
|
expect(isWeakPin("1234")).toBe(true);
|
||||||
|
expect(isWeakPin("4321")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags palindromes", () => {
|
||||||
|
expect(isWeakPin("1221")).toBe(true);
|
||||||
|
expect(isWeakPin("3443")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag random-looking pins", () => {
|
||||||
|
expect(isWeakPin("4739")).toBe(false);
|
||||||
|
expect(isWeakPin("8201")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hashPin / verifyPin", () => {
|
||||||
|
it("round-trips a 4-digit PIN", () => {
|
||||||
|
const h = hashPin("4739");
|
||||||
|
expect(verifyPin("4739", h)).toBe(true);
|
||||||
|
expect(verifyPin("4740", h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a random salt — same PIN produces different hashes", () => {
|
||||||
|
const a = hashPin("4739");
|
||||||
|
const b = hashPin("4739");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(verifyPin("4739", a)).toBe(true);
|
||||||
|
expect(verifyPin("4739", b)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a self-describing format (scrypt$N$r$p$salt$hash)", () => {
|
||||||
|
const h = hashPin("4739");
|
||||||
|
const parts = h.split("$");
|
||||||
|
expect(parts[0]).toBe("scrypt");
|
||||||
|
expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0);
|
||||||
|
expect(Number.parseInt(parts[2], 10)).toBeGreaterThan(0);
|
||||||
|
expect(Number.parseInt(parts[3], 10)).toBeGreaterThan(0);
|
||||||
|
// Salt is 16 bytes base64 — length depends on padding but always > 16
|
||||||
|
expect(parts[4].length).toBeGreaterThanOrEqual(16);
|
||||||
|
// Hash is 32 bytes base64
|
||||||
|
expect(parts[5].length).toBeGreaterThanOrEqual(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no exception) for tampered hashes", () => {
|
||||||
|
expect(verifyPin("4739", "not-a-real-hash")).toBe(false);
|
||||||
|
expect(verifyPin("4739", "scrypt$1$1$1$xx$yy")).toBe(false);
|
||||||
|
expect(verifyPin("4739", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for the wrong PIN (without revealing why)", () => {
|
||||||
|
const h = hashPin("4739");
|
||||||
|
expect(verifyPin("0000", h)).toBe(false);
|
||||||
|
expect(verifyPin("9999", h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the empty string (no bypass for missing PIN)", () => {
|
||||||
|
const h = hashPin("4739");
|
||||||
|
expect(verifyPin("", h)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatePin", () => {
|
||||||
|
it("returns a 4-digit string", () => {
|
||||||
|
const pin = generatePin();
|
||||||
|
expect(pin).toMatch(/^\d{4}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids obvious weak patterns (no 1111, 1234, etc.)", () => {
|
||||||
|
// 1000 attempts — should never see a weak pin if the generator works.
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const pin = generatePin();
|
||||||
|
expect(isWeakPin(pin)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces variety across many calls (statistical)", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
seen.add(generatePin());
|
||||||
|
}
|
||||||
|
// With 10k possible 4-digit pins, we'd expect to see well over 100
|
||||||
|
// distinct values in 200 draws. This is a sanity check, not a hard
|
||||||
|
// guarantee — if it ever flakes, increase the iteration count.
|
||||||
|
expect(seen.size).toBeGreaterThan(80);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PIN length constants", () => {
|
||||||
|
it("PINs are 4–8 digits", () => {
|
||||||
|
expect(PIN_MIN).toBe(4);
|
||||||
|
expect(PIN_MAX).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Tests for `src/lib/water-log-reporting.ts` — pure-function utilities
|
||||||
|
* used by both the admin and field clients. Covers:
|
||||||
|
* - irrigation season detection (in/out + year-wrapping)
|
||||||
|
* - entry shaping for the report format
|
||||||
|
* - filter by date range, headgate, user, submission source
|
||||||
|
* - CSV encoding (commas, quotes, newlines, unicode)
|
||||||
|
* - report formatter ("Daily Report" output for SMS/email)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isInIrrigationSeason,
|
||||||
|
shapeWaterLogEntry,
|
||||||
|
filterWaterLogEntries,
|
||||||
|
waterLogToCSV,
|
||||||
|
formatDailyWaterReport,
|
||||||
|
getDisplayAgeLabel,
|
||||||
|
getDisplayAgeColor,
|
||||||
|
type IrrigationSeasonSettings,
|
||||||
|
type WaterLogReportRow,
|
||||||
|
} from "@/lib/water-log-reporting";
|
||||||
|
|
||||||
|
const ROW = (overrides: Partial<WaterLogReportRow> = {}): WaterLogReportRow => ({
|
||||||
|
logged_at: "2026-06-15T14:30:00.000Z",
|
||||||
|
headgate_name: "Upper Ditch",
|
||||||
|
user_name: "Tyler",
|
||||||
|
user_role: "irrigator",
|
||||||
|
measurement: 3.2,
|
||||||
|
unit: "CFS",
|
||||||
|
notes: null,
|
||||||
|
submitted_via: "field",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isInIrrigationSeason", () => {
|
||||||
|
const season: IrrigationSeasonSettings = {
|
||||||
|
seasonStartMonth: 3,
|
||||||
|
seasonStartDay: 15,
|
||||||
|
seasonEndMonth: 10,
|
||||||
|
seasonEndDay: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct dates in *local* time (year, monthIndex, day) so the test
|
||||||
|
// is timezone-independent. `new Date("2026-03-15")` would parse as
|
||||||
|
// UTC midnight, which on a US TZ tester becomes the previous day
|
||||||
|
// locally and makes boundary assertions brittle.
|
||||||
|
const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
||||||
|
|
||||||
|
it("returns true inside the season (May 1)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 5, 1), season)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false before the season (Feb 1)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 2, 1), season)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false after the season (Dec 1)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 12, 1), season)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on the boundary day (Mar 15)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 3, 15), season)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on the closing boundary (Oct 15)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 10, 15), season)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false the day after closing (Oct 16)", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 10, 16), season)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a season that wraps the year (Nov → Feb)", () => {
|
||||||
|
const wrap: IrrigationSeasonSettings = {
|
||||||
|
seasonStartMonth: 11,
|
||||||
|
seasonStartDay: 1,
|
||||||
|
seasonEndMonth: 2,
|
||||||
|
seasonEndDay: 28,
|
||||||
|
};
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 12, 25), wrap)).toBe(true);
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 1, 15), wrap)).toBe(true);
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 6, 1), wrap)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default settings (Mar 15 → Oct 15) when none provided", () => {
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 4, 1))).toBe(true);
|
||||||
|
expect(isInIrrigationSeason(localDate(2026, 12, 1))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shapeWaterLogEntry", () => {
|
||||||
|
it("normalizes an entry, providing a default role", () => {
|
||||||
|
const out = shapeWaterLogEntry({
|
||||||
|
id: "x",
|
||||||
|
logged_at: "2026-06-15T14:30:00.000Z",
|
||||||
|
headgate_name: "Upper Ditch",
|
||||||
|
user_name: "Tyler",
|
||||||
|
user_role: "water_admin",
|
||||||
|
measurement: 3.2,
|
||||||
|
unit: "CFS",
|
||||||
|
notes: null,
|
||||||
|
submitted_via: "field",
|
||||||
|
});
|
||||||
|
expect(out).toEqual({
|
||||||
|
logged_at: "2026-06-15T14:30:00.000Z",
|
||||||
|
headgate_name: "Upper Ditch",
|
||||||
|
user_name: "Tyler",
|
||||||
|
user_role: "water_admin",
|
||||||
|
measurement: 3.2,
|
||||||
|
unit: "CFS",
|
||||||
|
notes: null,
|
||||||
|
submitted_via: "field",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults role to 'irrigator' when missing", () => {
|
||||||
|
const out = shapeWaterLogEntry({
|
||||||
|
id: "x",
|
||||||
|
logged_at: "2026-06-15T14:30:00.000Z",
|
||||||
|
headgate_name: "Upper Ditch",
|
||||||
|
user_name: "Tyler",
|
||||||
|
measurement: 3.2,
|
||||||
|
unit: "CFS",
|
||||||
|
notes: null,
|
||||||
|
submitted_via: "field",
|
||||||
|
});
|
||||||
|
expect(out.user_role).toBe("irrigator");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterWaterLogEntries", () => {
|
||||||
|
const rows: WaterLogReportRow[] = [
|
||||||
|
ROW({ logged_at: "2026-06-10T10:00:00.000Z", headgate_name: "A", user_name: "Tyler" }),
|
||||||
|
ROW({ logged_at: "2026-06-15T10:00:00.000Z", headgate_name: "B", user_name: "Tyler" }),
|
||||||
|
ROW({ logged_at: "2026-06-20T10:00:00.000Z", headgate_name: "A", user_name: "Sam" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns everything when no filters", () => {
|
||||||
|
expect(filterWaterLogEntries(rows, {})).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by dateFrom (inclusive of full day)", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15" });
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by dateTo (inclusive of full day)", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, { dateTo: "2026-06-15" });
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by date range", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, {
|
||||||
|
dateFrom: "2026-06-15",
|
||||||
|
dateTo: "2026-06-15",
|
||||||
|
});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by headgate", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, { headgateId: "A" });
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by user", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, { userId: "Sam" });
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by submission source", () => {
|
||||||
|
const rows2 = [
|
||||||
|
ROW({ submitted_via: "field" }),
|
||||||
|
ROW({ submitted_via: "admin" }),
|
||||||
|
];
|
||||||
|
expect(filterWaterLogEntries(rows2, { submittedVia: "admin" })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines multiple filters", () => {
|
||||||
|
const out = filterWaterLogEntries(rows, {
|
||||||
|
dateFrom: "2026-06-15",
|
||||||
|
headgateId: "A",
|
||||||
|
});
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].user_name).toBe("Sam");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("waterLogToCSV", () => {
|
||||||
|
it("emits a header row + one row per entry", () => {
|
||||||
|
const csv = waterLogToCSV([
|
||||||
|
ROW(),
|
||||||
|
ROW({ measurement: 4.5, notes: "rain" }),
|
||||||
|
]);
|
||||||
|
const lines = csv.split("\n");
|
||||||
|
expect(lines).toHaveLength(3);
|
||||||
|
expect(lines[0]).toContain("When");
|
||||||
|
expect(lines[0]).toContain("Headgate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes fields containing commas, quotes, or newlines", () => {
|
||||||
|
const csv = waterLogToCSV([
|
||||||
|
ROW({ notes: 'has, "quotes" and\nnewlines' }),
|
||||||
|
]);
|
||||||
|
// The cell value is properly quoted with internal quotes doubled.
|
||||||
|
// An embedded newline splits the row across two physical lines,
|
||||||
|
// so we read the full CSV string rather than a single line.
|
||||||
|
expect(csv).toContain('"has, ""quotes"" and\nnewlines"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes unicode and emoji", () => {
|
||||||
|
const csv = waterLogToCSV([
|
||||||
|
ROW({ user_name: "José 🚜" }),
|
||||||
|
]);
|
||||||
|
expect(csv).toContain("José 🚜");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a syntactically valid CSV (parses with a basic split)", () => {
|
||||||
|
const csv = waterLogToCSV([ROW(), ROW()]);
|
||||||
|
// 1 header + 2 data rows
|
||||||
|
expect(csv.split("\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDailyWaterReport", () => {
|
||||||
|
it("returns null when no rows and sendEvenIfEmpty is false", () => {
|
||||||
|
expect(formatDailyWaterReport([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits an empty-day message when sendEvenIfEmpty is true", () => {
|
||||||
|
const out = formatDailyWaterReport([], { sendEvenIfEmpty: true });
|
||||||
|
expect(out).toContain("No entries reported today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the recipient name when provided", () => {
|
||||||
|
const out = formatDailyWaterReport([ROW()], { recipientName: "David" });
|
||||||
|
// We don't enforce the salutation format, but the row must be present.
|
||||||
|
expect(out).toContain("Tyler");
|
||||||
|
expect(out).toContain("Upper Ditch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums the total measurement", () => {
|
||||||
|
const out = formatDailyWaterReport([
|
||||||
|
ROW({ measurement: 2 }),
|
||||||
|
ROW({ measurement: 3.5 }),
|
||||||
|
]);
|
||||||
|
expect(out).toContain("5.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes notes when present", () => {
|
||||||
|
const out = formatDailyWaterReport([ROW({ notes: "Ditch running heavy" })]);
|
||||||
|
expect(out).toContain("Ditch running heavy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display age helpers", () => {
|
||||||
|
it("returns 'never' for null", () => {
|
||||||
|
expect(getDisplayAgeLabel(null)).toBe("never");
|
||||||
|
expect(getDisplayAgeColor(null)).toBe("red");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'just now' under 1 minute", () => {
|
||||||
|
expect(getDisplayAgeLabel(0.5)).toBe("just now");
|
||||||
|
expect(getDisplayAgeColor(0.5)).toBe("green");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buckets by minutes / hours / days", () => {
|
||||||
|
expect(getDisplayAgeLabel(15)).toBe("15m ago");
|
||||||
|
expect(getDisplayAgeLabel(90)).toBe("1h ago");
|
||||||
|
expect(getDisplayAgeLabel(120)).toBe("2h ago");
|
||||||
|
expect(getDisplayAgeLabel(60 * 26)).toBe("1d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("colors by recency", () => {
|
||||||
|
expect(getDisplayAgeColor(15)).toBe("green"); // < 30m
|
||||||
|
expect(getDisplayAgeColor(60)).toBe("yellow"); // < 2h
|
||||||
|
expect(getDisplayAgeColor(180)).toBe("red"); // > 2h
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user