11cd2fd01a
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).
1012 lines
33 KiB
TypeScript
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 };
|