Files
route-commerce/src/actions/water-log/admin.ts
T
Tyler 11cd2fd01a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
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).
2026-06-17 11:36:00 -06:00

1012 lines
33 KiB
TypeScript

/**
* Water Log — admin-facing server actions.
*
* Two access paths:
* 1. Site-admin UI (`/admin/water-log`) — needs `can_manage_water_log`
* and runs brand-scoped via `withBrand()`.
* 2. PIN-protected field-admin UI (`/water/admin`) — uses a separate
* `water_admin_sessions` cookie; `requireWaterAdminSession()` is the
* gate for those.
*
* All actions return either `{ success: true, ... }` or
* `{ success: false, error }`. We never throw across the action boundary
* for expected errors (duplicate name, invalid input) — those are user
* feedback. Unexpected errors (DB outage, programmer error) propagate
* and surface in the toast as a generic message.
*/
"use server";
import { cookies } from "next/headers";
import { and, desc, eq, gte, lte, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import {
waterHeadgates,
waterIrrigators,
waterLogEntries,
waterAdminSessions,
type WaterHeadgate,
type WaterIrrigator,
type WaterLogEntry,
} from "@/db/schema/water-log";
import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin";
import { logAuditEvent, logAlert } from "@/lib/water-log-audit";
// ── Types used by both server and client ────────────────────────────────────
export type AdminHeadgate = {
id: string;
name: string;
status: "open" | "closed" | "maintenance";
unit: string;
active: boolean;
headgate_token: string;
last_used_at: string | null;
high_threshold: number | null;
low_threshold: number | null;
max_flow_gpm: number | null;
notes: string | null;
created_at: string;
};
export type AdminIrrigator = {
id: string;
name: string;
role: "irrigator" | "water_admin";
active: boolean;
language_preference: string;
phone: string | null;
notes: string | null;
last_used_at: string | null;
created_at: string;
};
export type AdminEntry = {
id: string;
headgate_id: string;
user_id: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
total_gallons: number | null;
method: "manual" | "meter" | "estimate" | "qr";
notes: string | null;
submitted_via: string;
photo_url: string | null;
logged_at: string;
logged_date: string | null;
};
export type WaterDisplayHeadgate = {
id: string;
name: string;
unit: string;
latest_entry: { measurement: number; user_name: string; logged_at: string } | null;
last_logged_at: string | null;
minutes_ago: number | null;
};
export type WaterDisplaySummary = {
headgates: WaterDisplayHeadgate[];
today_count: number;
today_total: number;
recent_entries: AdminEntry[];
};
export type AlertLogEntry = {
id: string;
alert_type: "high" | "low";
threshold_value: number;
reading_value: number;
message_sent: string | null;
sent_at: string;
created_at: string;
headgate_name: string;
formatted_time: string;
};
// ── Auth helpers ───────────────────────────────────────────────────────────
async function requireWaterAdminPermission() {
const adminUser = await getAdminUser();
if (!adminUser) return { ok: false as const, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { ok: false as const, error: "Not authorized" };
}
return { ok: true as const, adminUser };
}
/**
* For the `/water/admin` (PIN) portal. Returns the admin + brand if a
* valid session cookie is present. Falls back to a 401-shaped error.
*/
export async function requireWaterAdminSession(): Promise<
| { ok: true; adminUserId: string; brandId: string }
| { ok: false; error: string }
> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return { ok: false, error: "Not signed in" };
return withBrand(globalThis.__TUXEDO_BRAND_ID__ ?? "", async () => {
// session lookup must use platform-admin to read across brands
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select()
.from(waterAdminSessions)
.where(eq(waterAdminSessions.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return { ok: false as const, error: "Session not found" };
if (row.expiresAt.getTime() < Date.now()) {
return { ok: false as const, error: "Session expired" };
}
return {
ok: true as const,
adminUserId: row.adminUserId,
brandId: row.brandId,
};
});
});
}
// Global hint used by the field admin portal: water log is currently
// scoped to the Tuxedo brand only. Surfacing this in one place makes it
// easy to lift later when we add multi-tenant water log.
declare global {
// eslint-disable-next-line no-var
var __TUXEDO_BRAND_ID__: string | undefined;
}
globalThis.__TUXEDO_BRAND_ID__ ??= "64294306-5f42-463d-a5e8-2ad6c81a96de";
// ── Mappers (DB row → client shape) ────────────────────────────────────────
function mapHeadgate(h: WaterHeadgate): AdminHeadgate {
return {
id: h.id,
name: h.name,
status: (h.status as AdminHeadgate["status"]) ?? "open",
unit: h.unit ?? "CFS",
active: h.active,
headgate_token: h.headgateToken,
last_used_at: h.lastUsedAt?.toISOString() ?? null,
high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null,
low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null,
max_flow_gpm: h.maxFlowGpm != null ? Number(h.maxFlowGpm) : null,
notes: h.notes,
created_at: h.createdAt.toISOString(),
};
}
function mapIrrigator(i: WaterIrrigator): AdminIrrigator {
return {
id: i.id,
name: i.name,
role: (i.role as AdminIrrigator["role"]) ?? "irrigator",
active: i.active,
language_preference: i.languagePreference,
phone: i.phone,
notes: i.notes,
last_used_at: i.lastUsedAt?.toISOString() ?? null,
created_at: i.createdAt.toISOString(),
};
}
function mapEntry(
e: WaterLogEntry & { headgate_name: string; user_name: string },
): AdminEntry {
return {
id: e.id,
headgate_id: e.headgateId,
user_id: e.irrigatorId,
headgate_name: e.headgate_name,
user_name: e.user_name,
measurement: Number(e.measurement),
unit: e.unit,
total_gallons: e.totalGallons != null ? Number(e.totalGallons) : null,
method: (e.method as AdminEntry["method"]) ?? "manual",
notes: e.notes,
submitted_via: e.submittedVia,
photo_url: e.photoUrl,
logged_at: e.loggedAt.toISOString(),
logged_date: e.loggedDate,
};
}
// ── Headgate CRUD ──────────────────────────────────────────────────────────
export async function createWaterHeadgate(
brandId: string,
name: string,
unit: string = "CFS",
options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {},
): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (trimmed.length > 80) return { success: false, error: "Name is too long" };
return withBrand(brandId, async (db) => {
const existing = await db
.select({ id: waterHeadgates.id })
.from(waterHeadgates)
.where(
and(eq(waterHeadgates.brandId, brandId), eq(waterHeadgates.name, trimmed)),
)
.limit(1);
if (existing[0]) {
return { success: false, error: "A headgate with that name already exists" };
}
const [row] = await db
.insert(waterHeadgates)
.values({
brandId,
name: trimmed,
unit,
highThreshold: options.highThreshold?.toString() ?? null,
lowThreshold: options.lowThreshold?.toString() ?? null,
notes: options.notes ?? null,
headgateToken: `hg_${cryptoRandomHex(12)}`,
})
.returning();
if (!row) return { success: false, error: "Insert failed" };
await logAuditEvent({
brandId,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "create",
entityType: "headgate",
entityId: row.id,
details: { name: row.name, unit: row.unit },
});
return { success: true, headgate: mapHeadgate(row) };
});
}
export async function updateWaterHeadgate(
headgateId: string,
name: string,
active: boolean,
unit?: string,
highThreshold?: number | null,
lowThreshold?: number | null,
notes?: string | null,
status?: AdminHeadgate["status"],
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
// Headgates aren't directly brand-scoped in the URL — look up brand first.
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
return withBrand(brand, async (db) => {
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
const existing = await db
.select({ id: waterHeadgates.id })
.from(waterHeadgates)
.where(
and(
eq(waterHeadgates.brandId, brand),
eq(waterHeadgates.name, trimmed),
),
)
.limit(1);
if (existing[0] && existing[0].id !== headgateId) {
return { success: false, error: "Another headgate already uses that name" };
}
const updated = await db
.update(waterHeadgates)
.set({
name: trimmed,
active,
unit: unit ?? "CFS",
highThreshold: highThreshold?.toString() ?? null,
lowThreshold: lowThreshold?.toString() ?? null,
notes: notes ?? null,
status: status ?? "open",
})
.where(eq(waterHeadgates.id, headgateId))
.returning();
if (!updated[0]) return { success: false, error: "Headgate not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "update",
entityType: "headgate",
entityId: headgateId,
details: { name: trimmed, active, status, unit, highThreshold, lowThreshold },
});
return { success: true };
});
}
export async function deleteWaterHeadgate(
headgateId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
return withBrand(brand, async (db) => {
const deleted = await db
.delete(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.returning({ id: waterHeadgates.id });
if (!deleted[0]) return { success: false, error: "Headgate not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "delete",
entityType: "headgate",
entityId: headgateId,
});
return { success: true };
});
}
export async function regenerateHeadgateToken(
headgateId: string,
): Promise<{ success: boolean; token?: string; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
return withBrand(brand, async (db) => {
const token = `hg_${cryptoRandomHex(12)}`;
const updated = await db
.update(waterHeadgates)
.set({ headgateToken: token })
.where(eq(waterHeadgates.id, headgateId))
.returning({ token: waterHeadgates.headgateToken });
if (!updated[0]) return { success: false, error: "Headgate not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "regenerate_token",
entityType: "headgate",
entityId: headgateId,
});
return { success: true, token: updated[0].token };
});
}
export async function getWaterHeadgatesAdmin(
brandId: string,
): Promise<AdminHeadgate[]> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterHeadgates)
.where(eq(waterHeadgates.brandId, brandId))
.orderBy(desc(waterHeadgates.createdAt));
return rows.map(mapHeadgate);
});
}
// ── Irrigator (user) CRUD ──────────────────────────────────────────────────
export async function getWaterIrrigators(brandId: string): Promise<AdminIrrigator[]> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterIrrigators)
.where(eq(waterIrrigators.brandId, brandId))
.orderBy(desc(waterIrrigators.createdAt));
return rows.map(mapIrrigator);
});
}
export type CreateUserResult = {
success: boolean;
user?: AdminIrrigator;
pin?: string;
error?: string;
};
export async function createWaterUser(
brandId: string,
name: string,
role: "irrigator" | "water_admin" = "irrigator",
language: string = "en",
phone?: string | null,
): Promise<CreateUserResult> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (trimmed.length > 80) return { success: false, error: "Name is too long" };
if (!["en", "es"].includes(language)) {
return { success: false, error: "Invalid language" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
const result = await withBrand(brandId, async (db) => {
const existing = await db
.select({ id: waterIrrigators.id })
.from(waterIrrigators)
.where(
and(
eq(waterIrrigators.brandId, brandId),
eq(waterIrrigators.name, trimmed),
),
)
.limit(1);
if (existing[0]) {
return { success: false as const, error: "A user with that name already exists" };
}
const [row] = await db
.insert(waterIrrigators)
.values({
brandId,
name: trimmed,
pinHash,
role,
languagePreference: language,
phone: phone ?? null,
})
.returning();
if (!row) return { success: false as const, error: "Insert failed" };
return { success: true as const, user: row };
});
if (!result.success) return { success: false, error: result.error };
await logAuditEvent({
brandId,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "create",
entityType: "user",
entityId: result.user.id,
details: { name: result.user.name, role },
});
return { success: true, user: mapIrrigator(result.user), pin };
}
export async function createWaterIrrigator(
brandId: string,
name: string,
language: string = "en",
): Promise<CreateUserResult> {
return createWaterUser(brandId, name, "irrigator", language);
}
export async function updateWaterIrrigator(
irrigatorId: string,
name: string,
active: boolean,
language: string,
role: "irrigator" | "water_admin",
phone?: string | null,
notes?: string | null,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => {
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
if (!["en", "es"].includes(language)) {
return { success: false, error: "Invalid language" };
}
const updated = await db
.update(waterIrrigators)
.set({
name: trimmed,
active,
languagePreference: language,
role,
phone: phone ?? null,
notes: notes ?? null,
})
.where(eq(waterIrrigators.id, irrigatorId))
.returning();
if (!updated[0]) return { success: false, error: "User not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "update",
entityType: "user",
entityId: irrigatorId,
details: { name: trimmed, active, role, language },
});
return { success: true };
});
}
export async function deleteWaterUser(
userId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
return withBrand(brand, async (db) => {
const deleted = await db
.delete(waterIrrigators)
.where(eq(waterIrrigators.id, userId))
.returning({ id: waterIrrigators.id });
if (!deleted[0]) return { success: false, error: "User not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "delete",
entityType: "user",
entityId: userId,
});
return { success: true };
});
}
export async function resetWaterIrrigatorPin(
userId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brand, async (db) => {
const updated = await db
.update(waterIrrigators)
.set({ pinHash })
.where(eq(waterIrrigators.id, userId))
.returning({ id: waterIrrigators.id });
if (!updated[0]) return { success: false, error: "User not found" };
// Invalidate existing sessions for safety
await db.execute(
sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`,
);
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "reset_pin",
entityType: "user",
entityId: userId,
});
return { success: true, pin };
});
}
// ── Entry CRUD ─────────────────────────────────────────────────────────────
export async function getWaterEntries(
brandId: string,
limit: number = 50,
): Promise<AdminEntry[]> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
const safeLimit = Math.min(Math.max(limit, 1), 1000);
return withBrand(brandId, async (db) => {
const rows = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
headgate_name: string;
user_name: string;
measurement: string;
unit: string;
total_gallons: string | null;
method: string;
notes: string | null;
submitted_via: string;
photo_url: string | null;
logged_at: string;
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
h.name AS headgate_name,
i.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC
LIMIT ${safeLimit}
`);
return rows.rows.map((r) => ({
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
unit: r.unit,
total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null,
method: (r.method ?? "manual") as AdminEntry["method"],
notes: r.notes,
submitted_via: r.submitted_via,
photo_url: r.photo_url,
logged_at: r.logged_at,
logged_date: r.logged_date,
}));
});
}
export async function getWaterEntryById(entryId: string): Promise<AdminEntry | null> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return null;
return withBrand(brand, async (db) => {
const rows = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
headgate_name: string;
user_name: string;
measurement: string;
unit: string;
total_gallons: string | null;
method: string;
notes: string | null;
submitted_via: string;
photo_url: string | null;
logged_at: string;
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
h.name AS headgate_name,
i.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
WHERE e.id = ${entryId}
LIMIT 1
`);
const r = rows.rows[0];
if (!r) return null;
return {
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
unit: r.unit,
total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null,
method: (r.method ?? "manual") as AdminEntry["method"],
notes: r.notes,
submitted_via: r.submitted_via,
photo_url: r.photo_url,
logged_at: r.logged_at,
logged_date: r.logged_date,
};
});
}
export async function updateWaterEntry(
entryId: string,
measurement: number,
notes: string | null,
unit?: string,
method?: AdminEntry["method"],
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
}
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return { success: false, error: "Entry not found" };
return withBrand(brand, async (db) => {
const updated = await db
.update(waterLogEntries)
.set({
measurement: measurement.toString(),
unit: unit ?? "CFS",
method: method ?? "manual",
notes,
})
.where(eq(waterLogEntries.id, entryId))
.returning({ id: waterLogEntries.id });
if (!updated[0]) return { success: false, error: "Entry not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "update",
entityType: "entry",
entityId: entryId,
details: { measurement, unit, method },
});
return { success: true };
});
}
export async function deleteWaterEntry(
entryId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return { success: false, error: "Entry not found" };
return withBrand(brand, async (db) => {
const deleted = await db
.delete(waterLogEntries)
.where(eq(waterLogEntries.id, entryId))
.returning({ id: waterLogEntries.id });
if (!deleted[0]) return { success: false, error: "Entry not found" };
await logAuditEvent({
brandId: brand,
actorId: auth.adminUser.user_id ?? null,
actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin",
action: "delete",
entityType: "entry",
entityId: entryId,
});
return { success: true };
});
}
// ── Display summary (used by the field-admin dashboard) ────────────────────
export async function getWaterDisplaySummary(
brandId: string,
): Promise<WaterDisplaySummary | null> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
return withBrand(brandId, async (db) => {
// Headgates with their latest entry
const headgateRows = await db.execute<{
id: string;
name: string;
unit: string;
last_logged_at: string | null;
latest_user: string | null;
latest_measurement: string | null;
}>(sql`
SELECT
h.id, h.name, h.unit,
(
SELECT MAX(e.logged_at)
FROM water_log_entries e
WHERE e.headgate_id = h.id
) AS last_logged_at,
(
SELECT i.name
FROM water_log_entries e2
JOIN water_irrigators i ON i.id = e2.irrigator_id
WHERE e2.headgate_id = h.id
ORDER BY e2.logged_at DESC
LIMIT 1
) AS latest_user,
(
SELECT e3.measurement::text
FROM water_log_entries e3
WHERE e3.headgate_id = h.id
ORDER BY e3.logged_at DESC
LIMIT 1
) AS latest_measurement
FROM water_headgates h
WHERE h.brand_id = ${brandId}
ORDER BY h.name ASC
`);
const now = Date.now();
const headgates: WaterDisplayHeadgate[] = headgateRows.rows.map((r) => {
const last = r.last_logged_at ? new Date(r.last_logged_at) : null;
const minutesAgo = last ? Math.floor((now - last.getTime()) / 60000) : null;
return {
id: r.id,
name: r.name,
unit: r.unit ?? "CFS",
latest_entry:
r.latest_measurement != null && r.latest_user && r.last_logged_at
? {
measurement: Number(r.latest_measurement),
user_name: r.latest_user,
logged_at: r.last_logged_at,
}
: null,
last_logged_at: r.last_logged_at,
minutes_ago: minutesAgo,
};
});
// Today's entries
const todayCount = await db.execute<{ count: string; total: string | null }>(sql`
SELECT
COUNT(*)::text AS count,
COALESCE(SUM(measurement), 0)::text AS total
FROM water_log_entries
WHERE brand_id = ${brandId}
AND logged_date = (NOW() AT TIME ZONE 'UTC')::date
`);
const today = todayCount.rows[0] ?? { count: "0", total: "0" };
// Recent entries (last 10)
const recent = await db.execute<{
id: string;
headgate_id: string;
irrigator_id: string;
headgate_name: string;
user_name: string;
measurement: string;
unit: string;
total_gallons: string | null;
method: string;
notes: string | null;
submitted_via: string;
photo_url: string | null;
logged_at: string;
logged_date: string | null;
}>(sql`
SELECT
e.id, e.headgate_id, e.irrigator_id,
h.name AS headgate_name,
i.name AS user_name,
e.measurement::text, e.unit,
e.total_gallons::text, e.method, e.notes, e.submitted_via,
e.photo_url, e.logged_at, e.logged_date
FROM water_log_entries e
JOIN water_headgates h ON h.id = e.headgate_id
JOIN water_irrigators i ON i.id = e.irrigator_id
WHERE e.brand_id = ${brandId}
ORDER BY e.logged_at DESC
LIMIT 10
`);
return {
headgates,
today_count: parseInt(today.count, 10) || 0,
today_total: parseFloat(today.total ?? "0") || 0,
recent_entries: recent.rows.map((r) => ({
id: r.id,
headgate_id: r.headgate_id,
user_id: r.irrigator_id,
headgate_name: r.headgate_name,
user_name: r.user_name,
measurement: Number(r.measurement),
unit: r.unit,
total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null,
method: (r.method ?? "manual") as AdminEntry["method"],
notes: r.notes,
submitted_via: r.submitted_via,
photo_url: r.photo_url,
logged_at: r.logged_at,
logged_date: r.logged_date,
})),
};
});
}
export async function getWaterAlertLog(
brandId: string,
limit: number = 50,
): Promise<AlertLogEntry[]> {
const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db.execute<{
id: string;
alert_type: string;
threshold_value: string | null;
reading_value: string | null;
message_sent: string | null;
sent_at: string | null;
created_at: string;
headgate_name: string | null;
}>(sql`
SELECT
a.id, a.alert_type,
NULL::text AS threshold_value,
NULL::text AS reading_value,
a.message AS message_sent,
a.sent_at,
a.created_at,
h.name AS headgate_name
FROM water_alert_log a
LEFT JOIN water_headgates h ON h.id = a.headgate_id
WHERE a.brand_id = ${brandId}
ORDER BY a.created_at DESC
LIMIT ${Math.min(Math.max(limit, 1), 500)}
`);
return rows.rows.map((r) => ({
id: r.id,
alert_type: r.alert_type === "low" ? "low" : "high",
threshold_value: 0,
reading_value: 0,
message_sent: r.message_sent,
sent_at: r.sent_at ?? r.created_at,
created_at: r.created_at,
headgate_name: r.headgate_name ?? "Unknown",
formatted_time: new Date(r.created_at).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
}));
});
}
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Find the brand_id of a headgate by id. Uses `withPlatformAdmin` because
* we don't know which brand to scope the lookup to yet.
*/
async function withPlatformAdminBrandOf(headgateId: string): Promise<string | null> {
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ brandId: waterHeadgates.brandId })
.from(waterHeadgates)
.where(eq(waterHeadgates.id, headgateId))
.limit(1);
return rows[0]?.brandId ?? null;
});
}
async function withPlatformAdminBrandOfIrrigator(userId: string): Promise<string | null> {
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ brandId: waterIrrigators.brandId })
.from(waterIrrigators)
.where(eq(waterIrrigators.id, userId))
.limit(1);
return rows[0]?.brandId ?? null;
});
}
async function withPlatformAdminBrandOfEntry(entryId: string): Promise<string | null> {
const { withPlatformAdmin } = await import("@/db/client");
return withPlatformAdmin(async (db) => {
const rows = await db
.select({ brandId: waterLogEntries.brandId })
.from(waterLogEntries)
.where(eq(waterLogEntries.id, entryId))
.limit(1);
return rows[0]?.brandId ?? null;
});
}
function cryptoRandomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
// Use globalThis.crypto when available (Node 19+, all browsers).
// Fall back to require("crypto") for older runtimes.
if (typeof globalThis.crypto?.getRandomValues === "function") {
globalThis.crypto.getRandomValues(arr);
} else {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodeCrypto = require("node:crypto") as typeof import("node:crypto");
nodeCrypto.randomFillSync(arr);
}
return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
}
// Re-export for the field action to do an admin-PIN check.
export { verifyPin };
export { logAlert };