/** * 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 { const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db .select() .from(waterHeadgates) .where(eq(waterHeadgates.brandId, brandId)) .orderBy(desc(waterHeadgates.createdAt)); return rows.map(mapHeadgate); }); } // ── Irrigator (user) CRUD ────────────────────────────────────────────────── export async function getWaterIrrigators(brandId: string): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db .select() .from(waterIrrigators) .where(eq(waterIrrigators.brandId, brandId)) .orderBy(desc(waterIrrigators.createdAt)); return rows.map(mapIrrigator); }); } export type CreateUserResult = { success: boolean; user?: AdminIrrigator; pin?: string; error?: string; }; export async function createWaterUser( brandId: string, name: string, role: "irrigator" | "water_admin" = "irrigator", language: string = "en", phone?: string | null, ): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const trimmed = name?.trim(); if (!trimmed) return { success: false, error: "Name is required" }; if (trimmed.length > 80) return { success: false, error: "Name is too long" }; if (!["en", "es"].includes(language)) { return { success: false, error: "Invalid language" }; } const pin = generatePin(); const pinHash = hashPin(pin); const result = await withBrand(brandId, async (db) => { const existing = await db .select({ id: waterIrrigators.id }) .from(waterIrrigators) .where( and( eq(waterIrrigators.brandId, brandId), eq(waterIrrigators.name, trimmed), ), ) .limit(1); if (existing[0]) { return { success: false as const, error: "A user with that name already exists" }; } const [row] = await db .insert(waterIrrigators) .values({ brandId, name: trimmed, pinHash, role, languagePreference: language, phone: phone ?? null, }) .returning(); if (!row) return { success: false as const, error: "Insert failed" }; return { success: true as const, user: row }; }); if (!result.success) return { success: false, error: result.error }; await logAuditEvent({ brandId, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "create", entityType: "user", entityId: result.user.id, details: { name: result.user.name, role }, }); return { success: true, user: mapIrrigator(result.user), pin }; } export async function createWaterIrrigator( brandId: string, name: string, language: string = "en", ): Promise { return createWaterUser(brandId, name, "irrigator", language); } export async function updateWaterIrrigator( irrigatorId: string, name: string, active: boolean, language: string, role: "irrigator" | "water_admin", phone?: string | null, notes?: string | null, ): Promise<{ success: boolean; error?: string }> { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId); if (!brand) return { success: false, error: "User not found" }; return withBrand(brand, async (db) => { const trimmed = name?.trim(); if (!trimmed) return { success: false, error: "Name is required" }; if (!["en", "es"].includes(language)) { return { success: false, error: "Invalid language" }; } const updated = await db .update(waterIrrigators) .set({ name: trimmed, active, languagePreference: language, role, phone: phone ?? null, notes: notes ?? null, }) .where(eq(waterIrrigators.id, irrigatorId)) .returning(); if (!updated[0]) return { success: false, error: "User not found" }; await logAuditEvent({ brandId: brand, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "update", entityType: "user", entityId: irrigatorId, details: { name: trimmed, active, role, language }, }); return { success: true }; }); } export async function deleteWaterUser( userId: string, ): Promise<{ success: boolean; error?: string }> { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(userId); if (!brand) return { success: false, error: "User not found" }; return withBrand(brand, async (db) => { const deleted = await db .delete(waterIrrigators) .where(eq(waterIrrigators.id, userId)) .returning({ id: waterIrrigators.id }); if (!deleted[0]) return { success: false, error: "User not found" }; await logAuditEvent({ brandId: brand, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "delete", entityType: "user", entityId: userId, }); return { success: true }; }); } export async function resetWaterIrrigatorPin( userId: string, ): Promise<{ success: boolean; pin?: string; error?: string }> { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(userId); if (!brand) return { success: false, error: "User not found" }; const pin = generatePin(); const pinHash = hashPin(pin); return withBrand(brand, async (db) => { const updated = await db .update(waterIrrigators) .set({ pinHash }) .where(eq(waterIrrigators.id, userId)) .returning({ id: waterIrrigators.id }); if (!updated[0]) return { success: false, error: "User not found" }; // Invalidate existing sessions for safety await db.execute( sql`DELETE FROM water_sessions WHERE irrigator_id = ${userId}`, ); await logAuditEvent({ brandId: brand, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "reset_pin", entityType: "user", entityId: userId, }); return { success: true, pin }; }); } // ── Entry CRUD ───────────────────────────────────────────────────────────── export async function getWaterEntries( brandId: string, limit: number = 50, ): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; const safeLimit = Math.min(Math.max(limit, 1), 1000); return withBrand(brandId, async (db) => { const rows = await db.execute<{ id: string; headgate_id: string; irrigator_id: string; headgate_name: string; user_name: string; measurement: string; unit: string; total_gallons: string | null; method: string; notes: string | null; submitted_via: string; photo_url: string | null; logged_at: string; logged_date: string | null; }>(sql` SELECT e.id, e.headgate_id, e.irrigator_id, h.name AS headgate_name, i.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id JOIN water_irrigators i ON i.id = e.irrigator_id WHERE e.brand_id = ${brandId} ORDER BY e.logged_at DESC LIMIT ${safeLimit} `); return rows.rows.map((r) => ({ id: r.id, headgate_id: r.headgate_id, user_id: r.irrigator_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), unit: r.unit, total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, method: (r.method ?? "manual") as AdminEntry["method"], notes: r.notes, submitted_via: r.submitted_via, photo_url: r.photo_url, logged_at: r.logged_at, logged_date: r.logged_date, })); }); } export async function getWaterEntryById(entryId: string): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return null; const brand = await withPlatformAdminBrandOfEntry(entryId); if (!brand) return null; return withBrand(brand, async (db) => { const rows = await db.execute<{ id: string; headgate_id: string; irrigator_id: string; headgate_name: string; user_name: string; measurement: string; unit: string; total_gallons: string | null; method: string; notes: string | null; submitted_via: string; photo_url: string | null; logged_at: string; logged_date: string | null; }>(sql` SELECT e.id, e.headgate_id, e.irrigator_id, h.name AS headgate_name, i.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id JOIN water_irrigators i ON i.id = e.irrigator_id WHERE e.id = ${entryId} LIMIT 1 `); const r = rows.rows[0]; if (!r) return null; return { id: r.id, headgate_id: r.headgate_id, user_id: r.irrigator_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), unit: r.unit, total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, method: (r.method ?? "manual") as AdminEntry["method"], notes: r.notes, submitted_via: r.submitted_via, photo_url: r.photo_url, logged_at: r.logged_at, logged_date: r.logged_date, }; }); } export async function updateWaterEntry( entryId: string, measurement: number, notes: string | null, unit?: string, method?: AdminEntry["method"], ): Promise<{ success: boolean; error?: string }> { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; if (!Number.isFinite(measurement) || measurement < 0) { return { success: false, error: "Measurement must be a non-negative number" }; } const brand = await withPlatformAdminBrandOfEntry(entryId); if (!brand) return { success: false, error: "Entry not found" }; return withBrand(brand, async (db) => { const updated = await db .update(waterLogEntries) .set({ measurement: measurement.toString(), unit: unit ?? "CFS", method: method ?? "manual", notes, }) .where(eq(waterLogEntries.id, entryId)) .returning({ id: waterLogEntries.id }); if (!updated[0]) return { success: false, error: "Entry not found" }; await logAuditEvent({ brandId: brand, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "update", entityType: "entry", entityId: entryId, details: { measurement, unit, method }, }); return { success: true }; }); } export async function deleteWaterEntry( entryId: string, ): Promise<{ success: boolean; error?: string }> { const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfEntry(entryId); if (!brand) return { success: false, error: "Entry not found" }; return withBrand(brand, async (db) => { const deleted = await db .delete(waterLogEntries) .where(eq(waterLogEntries.id, entryId)) .returning({ id: waterLogEntries.id }); if (!deleted[0]) return { success: false, error: "Entry not found" }; await logAuditEvent({ brandId: brand, actorId: auth.adminUser.user_id ?? null, actorLabel: auth.adminUser.email ?? auth.adminUser.display_name ?? "admin", action: "delete", entityType: "entry", entityId: entryId, }); return { success: true }; }); } // ── Display summary (used by the field-admin dashboard) ──────────────────── export async function getWaterDisplaySummary( brandId: string, ): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return null; return withBrand(brandId, async (db) => { // Headgates with their latest entry const headgateRows = await db.execute<{ id: string; name: string; unit: string; last_logged_at: string | null; latest_user: string | null; latest_measurement: string | null; }>(sql` SELECT h.id, h.name, h.unit, ( SELECT MAX(e.logged_at) FROM water_log_entries e WHERE e.headgate_id = h.id ) AS last_logged_at, ( SELECT i.name FROM water_log_entries e2 JOIN water_irrigators i ON i.id = e2.irrigator_id WHERE e2.headgate_id = h.id ORDER BY e2.logged_at DESC LIMIT 1 ) AS latest_user, ( SELECT e3.measurement::text FROM water_log_entries e3 WHERE e3.headgate_id = h.id ORDER BY e3.logged_at DESC LIMIT 1 ) AS latest_measurement FROM water_headgates h WHERE h.brand_id = ${brandId} ORDER BY h.name ASC `); const now = Date.now(); const headgates: WaterDisplayHeadgate[] = headgateRows.rows.map((r) => { const last = r.last_logged_at ? new Date(r.last_logged_at) : null; const minutesAgo = last ? Math.floor((now - last.getTime()) / 60000) : null; return { id: r.id, name: r.name, unit: r.unit ?? "CFS", latest_entry: r.latest_measurement != null && r.latest_user && r.last_logged_at ? { measurement: Number(r.latest_measurement), user_name: r.latest_user, logged_at: r.last_logged_at, } : null, last_logged_at: r.last_logged_at, minutes_ago: minutesAgo, }; }); // Today's entries const todayCount = await db.execute<{ count: string; total: string | null }>(sql` SELECT COUNT(*)::text AS count, COALESCE(SUM(measurement), 0)::text AS total FROM water_log_entries WHERE brand_id = ${brandId} AND logged_date = (NOW() AT TIME ZONE 'UTC')::date `); const today = todayCount.rows[0] ?? { count: "0", total: "0" }; // Recent entries (last 10) const recent = await db.execute<{ id: string; headgate_id: string; irrigator_id: string; headgate_name: string; user_name: string; measurement: string; unit: string; total_gallons: string | null; method: string; notes: string | null; submitted_via: string; photo_url: string | null; logged_at: string; logged_date: string | null; }>(sql` SELECT e.id, e.headgate_id, e.irrigator_id, h.name AS headgate_name, i.name AS user_name, e.measurement::text, e.unit, e.total_gallons::text, e.method, e.notes, e.submitted_via, e.photo_url, e.logged_at, e.logged_date FROM water_log_entries e JOIN water_headgates h ON h.id = e.headgate_id JOIN water_irrigators i ON i.id = e.irrigator_id WHERE e.brand_id = ${brandId} ORDER BY e.logged_at DESC LIMIT 10 `); return { headgates, today_count: parseInt(today.count, 10) || 0, today_total: parseFloat(today.total ?? "0") || 0, recent_entries: recent.rows.map((r) => ({ id: r.id, headgate_id: r.headgate_id, user_id: r.irrigator_id, headgate_name: r.headgate_name, user_name: r.user_name, measurement: Number(r.measurement), unit: r.unit, total_gallons: r.total_gallons != null ? Number(r.total_gallons) : null, method: (r.method ?? "manual") as AdminEntry["method"], notes: r.notes, submitted_via: r.submitted_via, photo_url: r.photo_url, logged_at: r.logged_at, logged_date: r.logged_date, })), }; }); } export async function getWaterAlertLog( brandId: string, limit: number = 50, ): Promise { const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db.execute<{ id: string; alert_type: string; threshold_value: string | null; reading_value: string | null; message_sent: string | null; sent_at: string | null; created_at: string; headgate_name: string | null; }>(sql` SELECT a.id, a.alert_type, NULL::text AS threshold_value, NULL::text AS reading_value, a.message AS message_sent, a.sent_at, a.created_at, h.name AS headgate_name FROM water_alert_log a LEFT JOIN water_headgates h ON h.id = a.headgate_id WHERE a.brand_id = ${brandId} ORDER BY a.created_at DESC LIMIT ${Math.min(Math.max(limit, 1), 500)} `); return rows.rows.map((r) => ({ id: r.id, alert_type: r.alert_type === "low" ? "low" : "high", threshold_value: 0, reading_value: 0, message_sent: r.message_sent, sent_at: r.sent_at ?? r.created_at, created_at: r.created_at, headgate_name: r.headgate_name ?? "Unknown", formatted_time: new Date(r.created_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }), })); }); } // ── Helpers ──────────────────────────────────────────────────────────────── /** * Find the brand_id of a headgate by id. Uses `withPlatformAdmin` because * we don't know which brand to scope the lookup to yet. */ async function withPlatformAdminBrandOf(headgateId: string): Promise { const { withPlatformAdmin } = await import("@/db/client"); return withPlatformAdmin(async (db) => { const rows = await db .select({ brandId: waterHeadgates.brandId }) .from(waterHeadgates) .where(eq(waterHeadgates.id, headgateId)) .limit(1); return rows[0]?.brandId ?? null; }); } async function withPlatformAdminBrandOfIrrigator(userId: string): Promise { const { withPlatformAdmin } = await import("@/db/client"); return withPlatformAdmin(async (db) => { const rows = await db .select({ brandId: waterIrrigators.brandId }) .from(waterIrrigators) .where(eq(waterIrrigators.id, userId)) .limit(1); return rows[0]?.brandId ?? null; }); } async function withPlatformAdminBrandOfEntry(entryId: string): Promise { const { withPlatformAdmin } = await import("@/db/client"); return withPlatformAdmin(async (db) => { const rows = await db .select({ brandId: waterLogEntries.brandId }) .from(waterLogEntries) .where(eq(waterLogEntries.id, entryId)) .limit(1); return rows[0]?.brandId ?? null; }); } function cryptoRandomHex(bytes: number): string { const arr = new Uint8Array(bytes); // Use globalThis.crypto when available (Node 19+, all browsers). // Fall back to require("crypto") for older runtimes. if (typeof globalThis.crypto?.getRandomValues === "function") { globalThis.crypto.getRandomValues(arr); } else { // eslint-disable-next-line @typescript-eslint/no-require-imports const nodeCrypto = require("node:crypto") as typeof import("node:crypto"); nodeCrypto.randomFillSync(arr); } return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); } // Re-export for the field action to do an admin-PIN check. export { verifyPin }; export { logAlert };