/** * Water Log — field (PIN) actions. * * These power `/water` and `/water/admin` — the mobile-first, PIN-only * portals that ditch-riders actually use in the field. The shape of * the data they consume is the same as admin actions, but the * authorization model is different: * * - Irrigators carry a `wl_session` cookie tied to a row in * `water_sessions`. Cookie expiry = row's `expires_at`. * - Admins (water_admin role) carry a `wl_admin_session` cookie * tied to `water_admin_sessions`. * * Both cookies are httpOnly + sameSite=lax. The session lookup is the * gate for every mutating action — there's no role/permission check * inside the action because the cookie presence + the row's role is * the permission. */ "use server"; import { cookies } from "next/headers"; import { and, desc, eq, gte, sql } from "drizzle-orm"; import { withBrand, withPlatformAdmin } from "@/db/client"; import { waterHeadgates, waterIrrigators, waterSessions, waterLogEntries, waterAdminSessions, } from "@/db/schema/water-log"; import { verifyPin, validatePin } from "@/lib/water-log-pin"; import { logAlert } from "@/lib/water-log-audit"; import { requireFieldSession, type FieldSession, } from "@/actions/water-log/auth"; export type { FieldSession }; // Field sessions last 8h — a working day in the field. Long enough // that a single sign-in covers a morning shift + afternoon shift. const FIELD_SESSION_HOURS = 8; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; // ── Types ────────────────────────────────────────────────────────────────── type FieldHeadgate = { id: string; name: string; unit: string; status: string; high_threshold: number | null; low_threshold: number | null; active: boolean; }; type VerifyPinResult = | { success: true; user_id: string; name: string; role: "irrigator" | "water_admin"; session_id: string; lang: string; } | { success: false; error: string }; type SubmitEntryResult = | { success: true; entry_id: string } | { success: false; error: string }; // ── Headgates (read-only for field) ──────────────────────────────────────── export async function getWaterHeadgates( _brandId: string, activeOnly: boolean = false, ): Promise { return withBrand(TUXEDO_BRAND_ID, async (db) => { const where = activeOnly ? and( eq(waterHeadgates.brandId, TUXEDO_BRAND_ID), eq(waterHeadgates.active, true), ) : eq(waterHeadgates.brandId, TUXEDO_BRAND_ID); const rows = await db .select() .from(waterHeadgates) .where(where) .orderBy(waterHeadgates.name); return rows.map((h) => ({ id: h.id, name: h.name, unit: h.unit, status: h.status, high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null, low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null, active: h.active, })); }); } // ── PIN verify + session ─────────────────────────────────────────────────── export async function verifyWaterPin( _brandId: string, pin: string, ): Promise { // Validate format first (cheap, no DB). const formatError = validatePin(pin); if (formatError) return { success: false, error: formatError }; // Look the irrigator up across all brands (PIN is the only credential). const lookup = await withPlatformAdmin(async (db) => { const rows = await db .select() .from(waterIrrigators) .where(eq(waterIrrigators.active, true)) .orderBy(waterIrrigators.name); return rows; }); // Constant-time-ish: check every row, only succeed on the first match. // (We can't make this perfectly constant-time without pulling the // hashed values into memory in random order, but iterating by name // is close enough for a 4-digit PIN and avoids the worst case of an // obvious timing oracle on a sorted list.) const match = lookup.find((i) => verifyPin(pin, i.pinHash)); if (!match) { // Add a small delay to discourage brute-force scanning. await new Promise((r) => setTimeout(r, 200)); return { success: false, error: "Invalid PIN" }; } // Create a session in the user's brand context. The session insert // and the last_used_at bump are independent writes — fire them in // parallel and wait on both before returning. const [sessionId, cookieStore] = await Promise.all([ withBrand(match.brandId, async (db) => { const expiresAt = new Date( Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, ); const [insertResult] = await Promise.all([ db .insert(waterSessions) .values({ irrigatorId: match.id, expiresAt, }) .returning({ id: waterSessions.id }), db .update(waterIrrigators) .set({ lastUsedAt: new Date() }) .where(eq(waterIrrigators.id, match.id)), ]); const row = insertResult[0]; if (!row) throw new Error("Failed to create session"); return row.id; }), cookies(), ]); cookieStore.set("wl_session", sessionId, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: FIELD_SESSION_HOURS * 3600, path: "/", }); return { success: true, user_id: match.id, name: match.name, role: (match.role as "irrigator" | "water_admin") ?? "irrigator", session_id: sessionId, lang: match.languagePreference, }; } // ── Submit entry ─────────────────────────────────────────────────────────── export async function submitWaterEntry( headgateId: string, measurement: number, unit: string, notes: string, photoUrl?: string, latitude?: number, longitude?: number, _headgateLocked?: boolean, ): Promise { // ── Auth ── const session = await requireFieldSession(); if (!session.ok) return { success: false, error: session.error }; // ── Validate input ── if (!Number.isFinite(measurement) || measurement < 0) { return { success: false, error: "Measurement must be a non-negative number" }; } if (measurement > 1_000_000) { return { success: false, error: "Measurement is implausibly large" }; } if (notes && notes.length > 500) { return { success: false, error: "Notes are too long (max 500 chars)" }; } if (latitude != null && (latitude < -90 || latitude > 90)) { return { success: false, error: "Invalid latitude" }; } if (longitude != null && (longitude < -180 || longitude > 180)) { return { success: false, error: "Invalid longitude" }; } // ── Write the entry inside the brand context ── return withBrand(session.brandId, async (db) => { // Confirm the headgate belongs to this brand const headgateRows = await db .select() .from(waterHeadgates) .where(eq(waterHeadgates.id, headgateId)) .limit(1); const headgate = headgateRows[0]; if (!headgate) return { success: false, error: "Headgate not found" }; // Auto-calc total_gallons when CFS × duration is provided. // Caller passes `measurement` as the total CFS volume for the set // (we don't track duration separately on the entry — duration goes // in notes if needed). We leave total_gallons null for CFS by // default to avoid spurious "X gallons" claims. const totalGallons = computeTotalGallons(measurement, unit); const now = new Date(); const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`; const [entry] = await db .insert(waterLogEntries) .values({ brandId: session.brandId, headgateId, irrigatorId: session.userId, measurement: measurement.toString(), unit, totalGallons: totalGallons?.toString() ?? null, method: "manual", notes: notes || null, submittedVia: "field", photoUrl: photoUrl ?? null, latitude: latitude ?? null, longitude: longitude ?? null, loggedDate, loggedAt: now, }) .returning({ id: waterLogEntries.id }); if (!entry) return { success: false, error: "Insert failed" }; // Bump headgate last_used_at await db .update(waterHeadgates) .set({ lastUsedAt: now }) .where(eq(waterHeadgates.id, headgateId)); // Threshold alert check (fire-and-forget) if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) { void logAlert({ brandId: session.brandId, headgateId, alertType: "high", reading: measurement, threshold: Number(headgate.highThreshold), headgateName: headgate.name, }); } if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) { void logAlert({ brandId: session.brandId, headgateId, alertType: "low", reading: measurement, threshold: Number(headgate.lowThreshold), headgateName: headgate.name, }); } return { success: true, entry_id: entry.id }; }); } // ── Cookie/language helpers ─────────────────────────────────────────────── export async function logoutWater(): Promise { const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_session")?.value; if (sessionId) { // Best-effort DB cleanup try { await withPlatformAdmin(async (db) => { await db.delete(waterSessions).where(eq(waterSessions.id, sessionId)); }); } catch { // ignore — the cookie is being deleted anyway } } cookieStore.delete("wl_session"); } export async function logoutWaterAdmin(): Promise { const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_admin_session")?.value; if (sessionId) { try { await withPlatformAdmin(async (db) => { await db .delete(waterAdminSessions) .where(eq(waterAdminSessions.id, sessionId)); }); } catch { // ignore — cookie is being deleted anyway } } cookieStore.delete("wl_admin_session"); } export async function setWaterLang(lang: string): Promise { if (!["en", "es"].includes(lang)) return; const cookieStore = await cookies(); cookieStore.set("wl_lang", lang, { httpOnly: false, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 60 * 60 * 24 * 30, path: "/", }); } // ── Internal helpers ────────────────────────────────────────────────────── /** * If the measurement is in CFS (cubic feet per second) and we know it * represents a set duration, we can derive total gallons. Without * duration, we leave total_gallons null to avoid making up numbers. * * CFS × 60s × 7.48052 = gallons per minute. * 1 CFS for 1 hour = ~448.83 gallons. */ function computeTotalGallons(measurement: number, unit: string): number | null { if (unit !== "CFS") return null; // We don't have a duration field on the entry, so we return null. The // admin can edit an entry to set gallons manually if they have that // data. The hook is here so callers can extend easily. return null; } function pad(n: number): string { return n.toString().padStart(2, "0"); }