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_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
|
||||
- **Wholesale** — Wholesale portal settings
|
||||
- **Billing** — Plan and subscription management
|
||||
- **Water Log** — Irrigation tracking (add-on)
|
||||
- **Water Log** — Irrigation tracking (add-on) — see [docs/water-log.md](docs/water-log.md)
|
||||
- **Settings** — Brand settings, payments, apps
|
||||
|
||||
## Email Automations (Harvest Reach)
|
||||
|
||||
@@ -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 {
|
||||
pgTable,
|
||||
@@ -8,7 +18,13 @@ import {
|
||||
numeric,
|
||||
boolean,
|
||||
timestamp,
|
||||
date,
|
||||
jsonb,
|
||||
doublePrecision,
|
||||
index,
|
||||
uniqueIndex,
|
||||
integer,
|
||||
check,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { brands } from "./brands";
|
||||
import { adminUsers } from "./brands";
|
||||
@@ -21,11 +37,29 @@ export const waterHeadgates = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
/** Per-headgate opaque token used in the QR code. */
|
||||
headgateToken: text("headgate_token").notNull().unique(),
|
||||
/** Open / Closed / Maintenance. */
|
||||
status: text("status").notNull().default("open"),
|
||||
/** Display unit: CFS, GPM, Inches, AF/Day, etc. */
|
||||
unit: text("unit").notNull().default("CFS"),
|
||||
/** Optional max-flow marker in GPM. */
|
||||
maxFlowGpm: numeric("max_flow_gpm"),
|
||||
/** High-water alert threshold (units match `unit`). */
|
||||
highThreshold: numeric("high_threshold"),
|
||||
/** Low-water alert threshold (units match `unit`). */
|
||||
lowThreshold: numeric("low_threshold"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_headgates_brand_idx").on(t.brandId),
|
||||
tokenIdx: uniqueIndex("water_headgates_token_idx").on(t.headgateToken),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterIrrigators = pgTable(
|
||||
@@ -40,12 +74,19 @@ export const waterIrrigators = pgTable(
|
||||
languagePreference: text("language_preference")
|
||||
.notNull()
|
||||
.default("en"),
|
||||
/** "irrigator" submits entries only, "water_admin" can manage the brand. */
|
||||
role: text("role").notNull().default("irrigator"),
|
||||
phone: text("phone"),
|
||||
notes: text("notes"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandIdx: index("water_irrigators_brand_idx").on(t.brandId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterSessions = pgTable(
|
||||
@@ -75,10 +116,20 @@ export const waterLogEntries = pgTable(
|
||||
irrigatorId: uuid("irrigator_id")
|
||||
.notNull()
|
||||
.references(() => waterIrrigators.id, { onDelete: "cascade" }),
|
||||
/** Raw measurement value, in the entry's `unit`. */
|
||||
measurement: numeric("measurement").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
/** "manual" | "meter" | "estimate" | "qr" */
|
||||
method: text("method").notNull().default("manual"),
|
||||
/** Optional auto-computed total (in gallons) when CFS × duration is known. */
|
||||
totalGallons: numeric("total_gallons"),
|
||||
notes: text("notes"),
|
||||
submittedVia: text("submitted_via").notNull().default("app"),
|
||||
photoUrl: text("photo_url"),
|
||||
latitude: doublePrecision("latitude"),
|
||||
longitude: doublePrecision("longitude"),
|
||||
/** Date-only mirror of loggedAt for fast grouping / dashboard queries. */
|
||||
loggedDate: date("logged_date"),
|
||||
loggedAt: timestamp("logged_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
@@ -87,6 +138,14 @@ export const waterLogEntries = pgTable(
|
||||
(t) => ({
|
||||
brandIdx: index("water_log_entries_brand_idx").on(t.brandId),
|
||||
headgateIdx: index("water_log_entries_headgate_idx").on(t.headgateId),
|
||||
brandDateIdx: index("water_log_entries_brand_date_idx").on(
|
||||
t.brandId,
|
||||
t.loggedDate,
|
||||
),
|
||||
irrigatorIdx: index("water_log_entries_irrigator_idx").on(
|
||||
t.irrigatorId,
|
||||
t.loggedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,7 +157,9 @@ export const waterAlertLog = pgTable(
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
alertType: text("alert_type").notNull(),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id),
|
||||
headgateId: uuid("headgate_id").references(() => waterHeadgates.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
message: text("message").notNull(),
|
||||
sentTo: text("sent_to"),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
@@ -108,8 +169,89 @@ export const waterAlertLog = pgTable(
|
||||
},
|
||||
);
|
||||
|
||||
export const waterAdminSettings = pgTable("water_admin_settings", {
|
||||
brandId: uuid("brand_id")
|
||||
.primaryKey()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
/** Hashed admin PIN (scrypt $ N$r$p$salt$hash format). */
|
||||
pinHash: text("pin_hash"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
sessionDurationHours: integer("session_duration_hours")
|
||||
.notNull()
|
||||
.default(4),
|
||||
canEditEntries: boolean("can_edit_entries").notNull().default(true),
|
||||
canDeleteEntries: boolean("can_delete_entries").notNull().default(true),
|
||||
canExportCsv: boolean("can_export_csv").notNull().default(true),
|
||||
alertPhone: text("alert_phone"),
|
||||
alertsEnabled: boolean("alerts_enabled").notNull().default(false),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedBy: uuid("updated_by").references(() => adminUsers.id),
|
||||
});
|
||||
|
||||
export const waterAdminSessions = pgTable(
|
||||
"water_admin_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
adminUserId: uuid("admin_user_id")
|
||||
.notNull()
|
||||
.references(() => adminUsers.id, { onDelete: "cascade" }),
|
||||
pinHashUsed: text("pin_hash_used").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
adminIdx: index("water_admin_sessions_admin_idx").on(
|
||||
t.adminUserId,
|
||||
t.expiresAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const waterAuditLog = pgTable(
|
||||
"water_audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
brandId: uuid("brand_id")
|
||||
.notNull()
|
||||
.references(() => brands.id, { onDelete: "cascade" }),
|
||||
actorId: uuid("actor_id").references(() => adminUsers.id),
|
||||
actorLabel: text("actor_label").notNull(),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: uuid("entity_id"),
|
||||
details: jsonb("details"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
brandRecentIdx: index("water_audit_log_brand_recent_idx").on(
|
||||
t.brandId,
|
||||
t.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Inferred types (used by every action and client component) ────────────
|
||||
|
||||
export type WaterHeadgate = typeof waterHeadgates.$inferSelect;
|
||||
export type WaterHeadgateInsert = typeof waterHeadgates.$inferInsert;
|
||||
|
||||
export type WaterIrrigator = typeof waterIrrigators.$inferSelect;
|
||||
export type WaterIrrigatorInsert = typeof waterIrrigators.$inferInsert;
|
||||
|
||||
export type WaterSession = typeof waterSessions.$inferSelect;
|
||||
export type WaterLogEntry = typeof waterLogEntries.$inferSelect;
|
||||
export type WaterLogEntryInsert = typeof waterLogEntries.$inferInsert;
|
||||
|
||||
export type WaterAlertLog = typeof waterAlertLog.$inferSelect;
|
||||
export type WaterAdminSettings = typeof waterAdminSettings.$inferSelect;
|
||||
export type WaterAdminSession = typeof waterAdminSessions.$inferSelect;
|
||||
export type WaterAuditLog = typeof waterAuditLog.$inferSelect;
|
||||
|
||||
@@ -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";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
||||
import { withBrand, withPlatformAdmin } from "@/db/client";
|
||||
import {
|
||||
waterHeadgates,
|
||||
waterIrrigators,
|
||||
waterSessions,
|
||||
waterLogEntries,
|
||||
waterAdminSessions,
|
||||
} from "@/db/schema/water-log";
|
||||
import { verifyPin, validatePin } from "@/lib/water-log-pin";
|
||||
import { logAlert } from "@/lib/water-log-audit";
|
||||
|
||||
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
|
||||
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
|
||||
// `submit_water_entry`, `trigger_water_alert`,
|
||||
// `get_water_admin_session`) and tables (`water_headgates`,
|
||||
// `water_users`, `water_sessions`, `water_admin_sessions`,
|
||||
// `water_entries`, `water_alert_log`) that are not in the SaaS
|
||||
// rebuild's `db/schema/`. The actions below preserve the original
|
||||
// signatures and return empty / no-op responses so the field UI
|
||||
// degrades gracefully. See `actions/route-trace/lots.ts` for the
|
||||
// same pattern.
|
||||
// Field sessions last 8h — a working day in the field. Long enough
|
||||
// that a single sign-in covers a morning shift + afternoon shift.
|
||||
const FIELD_SESSION_HOURS = 8;
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type VerifyPinResult = {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
session_id: string;
|
||||
lang: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type SubmitEntryResult = {
|
||||
success: true;
|
||||
entry_id: string;
|
||||
} | {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
type FieldHeadgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
status: string;
|
||||
high_threshold: number | null;
|
||||
low_threshold: number | null;
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
||||
type VerifyPinResult =
|
||||
| {
|
||||
success: true;
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
session_id: string;
|
||||
lang: string;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
type SubmitEntryResult =
|
||||
| { success: true; entry_id: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ── Headgates (read-only for field) ────────────────────────────────────────
|
||||
|
||||
export async function getWaterHeadgates(
|
||||
_brandId: string,
|
||||
_activeOnly = false
|
||||
): Promise<Headgate[]> {
|
||||
return [];
|
||||
activeOnly: boolean = false,
|
||||
): Promise<FieldHeadgate[]> {
|
||||
return withBrand(TUXEDO_BRAND_ID, async (db) => {
|
||||
const where = activeOnly
|
||||
? and(
|
||||
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
|
||||
eq(waterHeadgates.active, true),
|
||||
)
|
||||
: eq(waterHeadgates.brandId, TUXEDO_BRAND_ID);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterHeadgates)
|
||||
.where(where)
|
||||
.orderBy(waterHeadgates.name);
|
||||
return rows.map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
unit: h.unit,
|
||||
status: h.status,
|
||||
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
|
||||
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
|
||||
active: h.active,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── PIN verify + session ───────────────────────────────────────────────────
|
||||
|
||||
export async function verifyWaterPin(
|
||||
_brandId: string,
|
||||
_pin: string
|
||||
pin: string,
|
||||
): Promise<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(
|
||||
_headgateId: string,
|
||||
_measurement: number,
|
||||
_unit: string,
|
||||
_notes: string,
|
||||
_photoUrl?: string,
|
||||
_latitude?: number,
|
||||
_longitude?: number,
|
||||
_headgateLocked?: boolean
|
||||
): Promise<SubmitEntryResult> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
// Look the irrigator up across all brands (PIN is the only credential).
|
||||
const lookup = await withPlatformAdmin(async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterIrrigators)
|
||||
.where(eq(waterIrrigators.active, true))
|
||||
.orderBy(waterIrrigators.name);
|
||||
return rows;
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
return { success: false, error: "Not logged in" };
|
||||
// Constant-time-ish: check every row, only succeed on the first match.
|
||||
// (We can't make this perfectly constant-time without pulling the
|
||||
// hashed values into memory in random order, but iterating by name
|
||||
// is close enough for a 4-digit PIN and avoids the worst case of an
|
||||
// obvious timing oracle on a sorted list.)
|
||||
const match = lookup.find((i) => verifyPin(pin, i.pinHash));
|
||||
if (!match) {
|
||||
// Add a small delay to discourage brute-force scanning.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
// Create a session in the user's brand context.
|
||||
const sessionId = await withBrand(match.brandId, async (db) => {
|
||||
const expiresAt = new Date(
|
||||
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const [row] = await db
|
||||
.insert(waterSessions)
|
||||
.values({
|
||||
irrigatorId: match.id,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterSessions.id });
|
||||
if (!row) throw new Error("Failed to create session");
|
||||
// Bump last_used_at for "I haven't seen this person in a while" UX
|
||||
await db
|
||||
.update(waterIrrigators)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(waterIrrigators.id, match.id));
|
||||
return row.id;
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: FIELD_SESSION_HOURS * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user_id: match.id,
|
||||
name: match.name,
|
||||
role: (match.role as "irrigator" | "water_admin") ?? "irrigator",
|
||||
session_id: sessionId,
|
||||
lang: match.languagePreference,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Submit entry ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function submitWaterEntry(
|
||||
headgateId: string,
|
||||
measurement: number,
|
||||
unit: string,
|
||||
notes: string,
|
||||
photoUrl?: string,
|
||||
latitude?: number,
|
||||
longitude?: number,
|
||||
_headgateLocked?: boolean,
|
||||
): Promise<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> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value;
|
||||
if (sessionId) {
|
||||
// Best-effort DB cleanup
|
||||
try {
|
||||
await withPlatformAdmin(async (db) => {
|
||||
await db.delete(waterSessions).where(eq(waterSessions.id, sessionId));
|
||||
});
|
||||
} catch {
|
||||
// ignore — the cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_session");
|
||||
}
|
||||
|
||||
export async function logoutWaterAdmin(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
if (sessionId) {
|
||||
try {
|
||||
await withPlatformAdmin(async (db) => {
|
||||
await db
|
||||
.delete(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.id, sessionId));
|
||||
});
|
||||
} catch {
|
||||
// ignore — cookie is being deleted anyway
|
||||
}
|
||||
}
|
||||
cookieStore.delete("wl_admin_session");
|
||||
}
|
||||
|
||||
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | 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> {
|
||||
if (!["en", "es"].includes(lang)) return;
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_lang", lang, {
|
||||
httpOnly: false,
|
||||
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWaterAdminSession(): Promise<{
|
||||
user_id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
} | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_admin_session")?.value;
|
||||
|
||||
if (!sessionId) return null;
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* If the measurement is in CFS (cubic feet per second) and we know it
|
||||
* represents a set duration, we can derive total gallons. Without
|
||||
* duration, we leave total_gallons null to avoid making up numbers.
|
||||
*
|
||||
* CFS × 60s × 7.48052 = gallons per minute.
|
||||
* 1 CFS for 1 hour = ~448.83 gallons.
|
||||
*/
|
||||
function computeTotalGallons(measurement: number, unit: string): number | null {
|
||||
if (unit !== "CFS") return null;
|
||||
// We don't have a duration field on the entry, so we return null. The
|
||||
// admin can edit an entry to set gallons manually if they have that
|
||||
// data. The hook is here so callers can extend easily.
|
||||
return null;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
@@ -1,44 +1,244 @@
|
||||
/**
|
||||
* Water Log — admin settings (PIN-protected /water/admin portal).
|
||||
*
|
||||
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
|
||||
* from the irrigators' PIN). That PIN is *hashed* and stored in
|
||||
* `water_admin_settings` (one row per brand). Sessions are tracked in
|
||||
* `water_admin_sessions`.
|
||||
*
|
||||
* There is currently no per-admin-user PIN — a single brand-wide admin
|
||||
* PIN. If you need per-user PINs (e.g. for an audit trail of which
|
||||
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
|
||||
* key sessions by `admin_user_id`.
|
||||
*/
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBrand } from "@/db/client";
|
||||
import {
|
||||
waterAdminSettings,
|
||||
waterAdminSessions,
|
||||
type WaterAdminSettings,
|
||||
} from "@/db/schema/water-log";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
|
||||
import { logAuditEvent } from "@/lib/water-log-audit";
|
||||
|
||||
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
|
||||
// `hash_water_admin_pin`, `save_water_admin_settings`,
|
||||
// `verify_water_admin_pin`) and the underlying
|
||||
// `water_admin_settings` table are not in the SaaS rebuild schema.
|
||||
// The functions below preserve the original signatures and return
|
||||
// empty / no-op responses. Same pattern as
|
||||
// `actions/route-trace/lots.ts`.
|
||||
|
||||
export type WaterAdminSettings = {
|
||||
export type AdminSettings = {
|
||||
enabled: boolean;
|
||||
session_duration_hours: number;
|
||||
can_edit_entries: boolean;
|
||||
can_delete_entries: boolean;
|
||||
can_export_csv: boolean;
|
||||
alert_phone?: string | null;
|
||||
alerts_enabled?: boolean;
|
||||
sessionDurationHours: number;
|
||||
canEditEntries: boolean;
|
||||
canDeleteEntries: boolean;
|
||||
canExportCsv: boolean;
|
||||
alertPhone: string | null;
|
||||
alertsEnabled: boolean;
|
||||
/** The currently configured PIN, in plain text. Only returned on
|
||||
* read right after the admin regenerates it; otherwise null. */
|
||||
pin: string | null;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
|
||||
function mapSettings(s: WaterAdminSettings): AdminSettings {
|
||||
return {
|
||||
enabled: s.enabled,
|
||||
sessionDurationHours: s.sessionDurationHours,
|
||||
canEditEntries: s.canEditEntries,
|
||||
canDeleteEntries: s.canDeleteEntries,
|
||||
canExportCsv: s.canExportCsv,
|
||||
alertPhone: s.alertPhone,
|
||||
alertsEnabled: s.alertsEnabled,
|
||||
pin: null, // never returned unless just regenerated
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
|
||||
return null;
|
||||
export async function getWaterAdminSettings(
|
||||
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(
|
||||
_brandId: string,
|
||||
_settings: Partial<WaterAdminSettings & { pin?: string }>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
brandId: string,
|
||||
settings: Partial<AdminSettings>,
|
||||
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
if (
|
||||
settings.sessionDurationHours != null &&
|
||||
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
|
||||
) {
|
||||
return { success: false, error: "Session duration must be 1–168 hours" };
|
||||
}
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
const update: Partial<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,
|
||||
_pin: string
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
return { success: false, error: NOT_CONFIGURED };
|
||||
/**
|
||||
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
|
||||
* plaintext PIN *once* — caller is responsible for showing it to the
|
||||
* admin and never persisting it.
|
||||
*/
|
||||
export async function regenerateAdminPin(
|
||||
brandId: string,
|
||||
): Promise<{ success: boolean; pin?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (
|
||||
!adminUser.can_manage_water_log &&
|
||||
adminUser.role !== "platform_admin"
|
||||
) {
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
const pin = generatePin();
|
||||
const pinHash = hashPin(pin);
|
||||
return withBrand(brandId, async (db) => {
|
||||
await db
|
||||
.insert(waterAdminSettings)
|
||||
.values({ brandId, pinHash })
|
||||
.onConflictDoUpdate({
|
||||
target: waterAdminSettings.brandId,
|
||||
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
|
||||
});
|
||||
// Invalidate any existing admin sessions for safety
|
||||
await db
|
||||
.delete(waterAdminSessions)
|
||||
.where(eq(waterAdminSessions.brandId, brandId));
|
||||
await logAuditEvent({
|
||||
brandId,
|
||||
actorId: adminUser.user_id ?? null,
|
||||
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
|
||||
action: "regenerate_admin_pin",
|
||||
entityType: "settings",
|
||||
});
|
||||
return { success: true, pin };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PIN verify for the `/water/admin` portal. Creates a session row and
|
||||
* sets the `wl_admin_session` cookie.
|
||||
*/
|
||||
export async function verifyWaterAdminPin(
|
||||
brandId: string,
|
||||
pin: string,
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
const formatError = validatePin(pin);
|
||||
if (formatError) return { success: false, error: formatError };
|
||||
|
||||
return withBrand(brandId, async (db) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(waterAdminSettings)
|
||||
.where(eq(waterAdminSettings.brandId, brandId))
|
||||
.limit(1);
|
||||
const settings = rows[0];
|
||||
if (!settings) {
|
||||
return { success: false, error: "Admin portal not configured" };
|
||||
}
|
||||
if (!settings.enabled) {
|
||||
return { success: false, error: "Admin portal is disabled" };
|
||||
}
|
||||
if (!settings.pinHash) {
|
||||
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
|
||||
}
|
||||
if (!verifyPin(pin, settings.pinHash)) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
// Tie the session to the calling site admin (best-effort).
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
|
||||
);
|
||||
const [session] = await db
|
||||
.insert(waterAdminSessions)
|
||||
.values({
|
||||
brandId,
|
||||
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
|
||||
pinHashUsed: settings.pinHash,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({ id: waterAdminSessions.id });
|
||||
if (!session) return { success: false, error: "Failed to create session" };
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("wl_admin_session", session.id, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: settings.sessionDurationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return { success: true, session_id: session.id };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* /admin/water-log/settings
|
||||
*
|
||||
* Site-admin-facing settings page for the Water Log module. Controls:
|
||||
* - Whether `/water/admin` is enabled
|
||||
* - 4-digit admin PIN (regenerate on demand)
|
||||
* - Session duration (hours)
|
||||
* - Per-coarse-permission flags (edit / delete / export)
|
||||
* - High/low alert phone + enable flag
|
||||
*
|
||||
* Visual language: Field Almanac. Same cream + forest palette as
|
||||
* the main WaterLogAdminPanel, but trimmed to a single column for
|
||||
* a focused "form" feel.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
getWaterAdminSettings,
|
||||
saveWaterAdminSettings,
|
||||
regenerateAdminPin,
|
||||
type AdminSettings,
|
||||
} from "@/actions/water-log/settings";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default function WaterLogSettingsPage() {
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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);
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pin, setPin] = useState("");
|
||||
const [confirmPin, setConfirmPin] = useState("");
|
||||
const [sessionDuration, setSessionDuration] = useState(12);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [canDelete, setCanDelete] = useState(false);
|
||||
const [canDelete, setCanDelete] = useState(true);
|
||||
const [canExport, setCanExport] = useState(true);
|
||||
const [alertPhone, setAlertPhone] = useState("");
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
@@ -30,239 +48,298 @@ export default function WaterLogSettingsPage() {
|
||||
if (data) {
|
||||
setSettings(data);
|
||||
setEnabled(data.enabled);
|
||||
setSessionDuration(data.session_duration_hours);
|
||||
setCanEdit(data.can_edit_entries);
|
||||
setCanDelete(data.can_delete_entries);
|
||||
setCanExport(data.can_export_csv);
|
||||
setAlertPhone(data.alert_phone ?? "");
|
||||
setAlertsEnabled(data.alerts_enabled ?? false);
|
||||
setSessionDuration(data.sessionDurationHours);
|
||||
setCanEdit(data.canEditEntries);
|
||||
setCanDelete(data.canDeleteEntries);
|
||||
setCanExport(data.canExportCsv);
|
||||
setAlertPhone(data.alertPhone ?? "");
|
||||
setAlertsEnabled(data.alertsEnabled);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await loadSettings();
|
||||
};
|
||||
init();
|
||||
// Load on mount — setState-in-effect is intentional here
|
||||
// because we're hydrating from server data.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (enabled && pin && pin !== confirmPin) {
|
||||
setMessage({ type: "error", text: "PINs do not match" });
|
||||
return;
|
||||
}
|
||||
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
|
||||
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||||
enabled,
|
||||
pin: pin || undefined,
|
||||
session_duration_hours: sessionDuration,
|
||||
can_edit_entries: canEdit,
|
||||
can_delete_entries: canDelete,
|
||||
can_export_csv: canExport,
|
||||
alert_phone: alertPhone || null,
|
||||
alerts_enabled: alertsEnabled,
|
||||
sessionDurationHours: sessionDuration,
|
||||
canEditEntries: canEdit,
|
||||
canDeleteEntries: canDelete,
|
||||
canExportCsv: canExport,
|
||||
alertPhone: alertPhone || null,
|
||||
alertsEnabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({ type: "success", text: "Settings saved" });
|
||||
setPin("");
|
||||
setConfirmPin("");
|
||||
if (result.settings) setSettings(result.settings);
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (!confirm("Regenerate the admin PIN? All current admin sessions will be signed out.")) {
|
||||
return;
|
||||
}
|
||||
setRegenerating(true);
|
||||
setMessage(null);
|
||||
const result = await regenerateAdminPin(TUXEDO_BRAND_ID);
|
||||
if (result.success && result.pin) {
|
||||
setRevealedPin(result.pin);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "New PIN generated. Save it now — it will not be shown again.",
|
||||
});
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Failed to regenerate PIN" });
|
||||
}
|
||||
setRegenerating(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<span className="text-zinc-500">Loading...</span>
|
||||
<div className="min-h-screen bg-[#fdfaf2] flex items-center justify-center font-sans text-[#1d1d1f]">
|
||||
<span className="text-[#5a5d5a]">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="min-h-screen bg-[#fdfaf2] font-sans text-[#1d1d1f]">
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm">← Back</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100">Water Log — Admin Portal</h1>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-[#d4d9d3] bg-white">
|
||||
<div className="mx-auto max-w-2xl px-6 py-6">
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.3em] text-[#8a6b3b]">§ 04 — Settings</p>
|
||||
<h1
|
||||
className="mt-2 text-3xl font-medium text-[#1a4d2e]"
|
||||
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
|
||||
>
|
||||
Water Log · Admin Portal
|
||||
</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 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">
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<Card title="Admin Portal" subtitle="PIN-based access to /water/admin">
|
||||
<ToggleRow
|
||||
label="Enable"
|
||||
description="Allow water admins to sign in with a 4-digit PIN."
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* PIN */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
|
||||
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
|
||||
<input
|
||||
id="water-new-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
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>
|
||||
<Card title="Admin PIN" subtitle="4-digit PIN required for /water/admin sign-in">
|
||||
{revealedPin ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-mono uppercase tracking-wider text-[#8a6b3b]">
|
||||
New PIN — write it down
|
||||
</p>
|
||||
<div className="rounded-lg border border-[#1a4d2e] bg-[#1a4d2e]/5 px-6 py-4 text-center">
|
||||
<span
|
||||
className="font-mono text-4xl font-bold tracking-[0.4em] text-[#1a4d2e]"
|
||||
aria-label={`PIN: ${revealedPin.split("").join(" ")}`}
|
||||
>
|
||||
{revealedPin}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setter((v: boolean) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
|
||||
onClick={() => setRevealedPin(null)}
|
||||
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>
|
||||
</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 */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
|
||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<Card title="High / Low Alerts" subtitle="SMS a phone when a reading exceeds a headgate's thresholds.">
|
||||
<ToggleRow
|
||||
label="Enable alerts"
|
||||
description="Threshold breach notifications."
|
||||
value={alertsEnabled}
|
||||
onChange={setAlertsEnabled}
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<div>
|
||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
|
||||
Alert phone number
|
||||
</label>
|
||||
<input
|
||||
id="water-alert-phone"
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
onChange={(e) => setAlertPhone(e.target.value)}
|
||||
placeholder="+1234567890"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
|
||||
placeholder="+13035551234"
|
||||
className="mt-1 w-full rounded-lg border border-[#d4d9d3] bg-white px-4 py-2.5 text-base outline-none focus:border-[#1a4d2e] focus:ring-1 focus:ring-[#1a4d2e]"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
</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 { cookies } from "next/headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { verifyWaterAdminPin } from "@/actions/water-log/settings";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { brandId, pin } = await request.json();
|
||||
const { brandId, pin } = (await request.json()) as {
|
||||
brandId?: string;
|
||||
pin?: string;
|
||||
};
|
||||
if (!brandId || !pin) {
|
||||
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Missing brandId or pin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get admin settings
|
||||
const settingsRes = await pool.query<{
|
||||
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
|
||||
}>(
|
||||
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
|
||||
[brandId],
|
||||
);
|
||||
const settings = settingsRes.rows[0]?.get_water_admin_settings;
|
||||
if (!settings?.enabled) {
|
||||
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
|
||||
const result = await verifyWaterAdminPin(brandId, pin);
|
||||
if (!result.success) {
|
||||
// Don't leak whether the PIN was wrong vs. not configured — both
|
||||
// are the same to a field attacker probing the endpoint.
|
||||
const status = result.error === "Invalid PIN" ? 401 : 403;
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid PIN" },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
const verifyRes = await pool.query<{
|
||||
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
|
||||
}>(
|
||||
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
|
||||
[brandId, pin],
|
||||
);
|
||||
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
|
||||
if (!verifyData?.success || !verifyData.session_id) {
|
||||
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Create session cookie
|
||||
const sessionId = verifyData.session_id;
|
||||
const cookieStore = await cookies();
|
||||
const durationHours = settings.session_duration_hours ?? 4;
|
||||
cookieStore.set("wl_admin_session", sessionId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: durationHours * 3600,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
|
||||
} catch (err) {
|
||||
captureError(err as Error, { path: "/api/water-admin-auth" });
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,86 @@
|
||||
/**
|
||||
* GET /api/water-logs/export
|
||||
*
|
||||
* Streams the water log as JSON or CSV. Admin-only — checked via
|
||||
* `getAdminUser()` + `can_manage_water_log`. Brand comes from the admin
|
||||
* session, with Tuxedo fallback for backward compat.
|
||||
*
|
||||
* GET /api/water-logs/export?format=csv
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { pool } from "@/lib/db";
|
||||
import { getWaterEntries } from "@/actions/water-log/admin";
|
||||
import type { WaterLogReportRow } from "@/lib/water-log-reporting";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!adminUser.can_manage_water_log) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const format = searchParams.get("format") ?? "json";
|
||||
const brandId =
|
||||
adminUser.brand_id ??
|
||||
process.env.TUXEDO_BRAND_ID ??
|
||||
TUXEDO_BRAND_ID;
|
||||
|
||||
// Use brand_id from session (always Tuxedo for water log) or fallback to env
|
||||
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
headgate_id: string | null;
|
||||
measurement: number | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
|
||||
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
|
||||
[brandId, 10000],
|
||||
);
|
||||
const entries = data[0]?.get_water_entries ?? [];
|
||||
const raw = await getWaterEntries(brandId, 10000);
|
||||
const rows: WaterLogReportRow[] = raw.map((r) => ({
|
||||
logged_at: r.logged_at,
|
||||
headgate_name: r.headgate_name,
|
||||
user_name: r.user_name,
|
||||
user_role: "irrigator",
|
||||
measurement: r.measurement,
|
||||
unit: r.unit,
|
||||
notes: r.notes,
|
||||
submitted_via: r.submitted_via,
|
||||
}));
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
|
||||
const csvRows = [headers.join(",")];
|
||||
for (const row of entries) {
|
||||
csvRows.push([
|
||||
row.id,
|
||||
row.user_id ?? "",
|
||||
row.headgate_id ?? "",
|
||||
row.measurement ?? "",
|
||||
row.unit ?? "",
|
||||
`"${(row.notes ?? "").replace(/"/g, '""')}"`,
|
||||
row.created_at ?? "",
|
||||
].join(","));
|
||||
const headers = [
|
||||
"When",
|
||||
"Headgate",
|
||||
"User",
|
||||
"Measurement",
|
||||
"Unit",
|
||||
"Total Gallons",
|
||||
"Method",
|
||||
"Notes",
|
||||
"Via",
|
||||
"Photo URL",
|
||||
];
|
||||
const esc = (s: string | null | undefined) =>
|
||||
`"${(s ?? "").replace(/"/g, '""')}"`;
|
||||
const lines: string[] = [headers.join(",")];
|
||||
for (const e of raw) {
|
||||
lines.push(
|
||||
[
|
||||
esc(e.logged_at),
|
||||
esc(e.headgate_name),
|
||||
esc(e.user_name),
|
||||
String(e.measurement),
|
||||
esc(e.unit),
|
||||
e.total_gallons != null ? String(e.total_gallons) : "",
|
||||
esc(e.method),
|
||||
esc(e.notes),
|
||||
esc(e.submitted_via),
|
||||
esc(e.photo_url),
|
||||
].join(","),
|
||||
);
|
||||
}
|
||||
return new NextResponse(csvRows.join("\n"), {
|
||||
return new NextResponse(lines.join("\n"), {
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="water-log-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(entries);
|
||||
return NextResponse.json({ rows });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -361,8 +361,8 @@ export default function WaterAdminClient() {
|
||||
setSavingUser(true);
|
||||
setNewUserError(null);
|
||||
const result = await createWaterIrrigator(TUXEDO_BRAND_ID, newUserName.trim(), newUserLang);
|
||||
if (result.success && result.pin) {
|
||||
setNewPin({ name: result.irrigator!.name, pin: result.pin });
|
||||
if (result.success && result.pin && result.user) {
|
||||
setNewPin({ name: result.user.name, pin: result.pin });
|
||||
setNewUserName("");
|
||||
setShowAddUser(false);
|
||||
const updated = await getWaterIrrigators(TUXEDO_BRAND_ID);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
setWaterLang,
|
||||
} from "@/actions/water-log/field";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { WaterGauge } from "@/components/water/icons";
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
@@ -345,10 +346,13 @@ function WaterFieldInner() {
|
||||
// Language selection screen
|
||||
if (step === "loading") {
|
||||
return (
|
||||
<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-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-stone-500 text-base">Loading...</p>
|
||||
<div className="mx-auto mb-4 flex justify-center">
|
||||
<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>
|
||||
);
|
||||
@@ -357,20 +361,31 @@ function WaterFieldInner() {
|
||||
// Language selection screen
|
||||
if (step === "lang") {
|
||||
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">
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.selectLang}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<WaterGauge size={56} level={null} status="open" />
|
||||
</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
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -383,26 +398,31 @@ function WaterFieldInner() {
|
||||
// Role selection screen
|
||||
if (step === "role") {
|
||||
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">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.whoAreYou}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<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-8">{t.whoAreYou}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -415,16 +435,21 @@ function WaterFieldInner() {
|
||||
// PIN entry screen
|
||||
if (step === "pin") {
|
||||
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">
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-stone-900 mb-2 text-center">{t.title}</h1>
|
||||
<p className="text-stone-500 text-center mb-8">{t.enterPin}</p>
|
||||
<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-8">{t.enterPin}</p>
|
||||
|
||||
<form onSubmit={handlePinSubmit}>
|
||||
<input
|
||||
@@ -435,17 +460,17 @@ function WaterFieldInner() {
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t.pinPlaceholder}
|
||||
className="w-full rounded-xl border-2 border-stone-300 px-6 py-6 text-center text-4xl font-bold tracking-widest outline-none focus:border-stone-900 mb-4"
|
||||
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
|
||||
style={{ letterSpacing: "0.3em" }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<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
|
||||
type="submit"
|
||||
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}
|
||||
</button>
|
||||
@@ -459,17 +484,27 @@ function WaterFieldInner() {
|
||||
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<div className="min-h-screen bg-[#fdfaf2]">
|
||||
{/* 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>
|
||||
<h1 className="text-xl font-bold text-white">{t.title}</h1>
|
||||
<p className="text-sm text-stone-400">{t.loggedInAs}: {irrigatorName}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<WaterGauge size={32} level={null} status="open" />
|
||||
<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>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
@@ -477,7 +512,7 @@ function WaterFieldInner() {
|
||||
</div>
|
||||
|
||||
{/* 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="flex items-center gap-1.5">
|
||||
{["lang", "role", "pin", "form"].map((s, i) => {
|
||||
@@ -486,8 +521,8 @@ function WaterFieldInner() {
|
||||
const isPast = stepIndex > i;
|
||||
return (
|
||||
<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"}`} />
|
||||
{i < 3 && <div className={`w-6 h-0.5 ${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-[#22c55e]" : "bg-[#d4d9d3]"}`} />}
|
||||
</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