/** * 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 { getSession } from "@/lib/auth"; // 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 { await getSession(); 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 { await getSession(); // 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 { await getSession(); // ── Auth ── const session = await requireFieldSession(); if (!session.ok) return { success: false, error: session.error }; // ── Validate input ── if (!Number.isFinite(measurement) || measurement < 0) { return { success: false, error: "Measurement must be a non-negative number" }; } if (measurement > 1_000_000) { return { success: false, error: "Measurement is implausibly large" }; } if (notes && notes.length > 500) { return { success: false, error: "Notes are too long (max 500 chars)" }; } if (latitude != null && (latitude < -90 || latitude > 90)) { return { success: false, error: "Invalid latitude" }; } if (longitude != null && (longitude < -180 || longitude > 180)) { return { success: false, error: "Invalid longitude" }; } // ── Write the entry inside the brand context ── return withBrand(session.brandId, async (db) => { // Confirm the headgate belongs to this brand const headgateRows = await db .select() .from(waterHeadgates) .where(eq(waterHeadgates.id, headgateId)) .limit(1); const headgate = headgateRows[0]; if (!headgate) return { success: false, error: "Headgate not found" }; // Auto-calc total_gallons when CFS × duration is provided. // Caller passes `measurement` as the total CFS volume for the set // (we don't track duration separately on the entry — duration goes // in notes if needed). We leave total_gallons null for CFS by // default to avoid spurious "X gallons" claims. const totalGallons = computeTotalGallons(measurement, unit); const now = new Date(); const loggedDate = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`; const [entry] = await db .insert(waterLogEntries) .values({ brandId: session.brandId, headgateId, irrigatorId: session.userId, measurement: measurement.toString(), unit, totalGallons: totalGallons?.toString() ?? null, method: "manual", notes: notes || null, submittedVia: "field", photoUrl: photoUrl ?? null, latitude: latitude ?? null, longitude: longitude ?? null, loggedDate, loggedAt: now, }) .returning({ id: waterLogEntries.id }); if (!entry) return { success: false, error: "Insert failed" }; // Bump headgate last_used_at await db .update(waterHeadgates) .set({ lastUsedAt: now }) .where(eq(waterHeadgates.id, headgateId)); // Threshold alert check (fire-and-forget) if (headgate.highThreshold != null && measurement > Number(headgate.highThreshold)) { void logAlert({ brandId: session.brandId, headgateId, alertType: "high", reading: measurement, threshold: Number(headgate.highThreshold), headgateName: headgate.name, }); } if (headgate.lowThreshold != null && measurement < Number(headgate.lowThreshold)) { void logAlert({ brandId: session.brandId, headgateId, alertType: "low", reading: measurement, threshold: Number(headgate.lowThreshold), headgateName: headgate.name, }); } return { success: true, entry_id: entry.id }; }); } // ── Session helpers (shared with admin.ts via requireFieldSession) ──────── type FieldSession = | { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" } | { ok: false; error: string }; async function requireFieldSession(): Promise { await getSession(); const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_session")?.value; if (!sessionId) return { ok: false, error: "Not logged in" }; return withPlatformAdmin(async (db) => { const rows = await db .select({ session: waterSessions, irrigator: waterIrrigators, }) .from(waterSessions) .innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId)) .where(eq(waterSessions.id, sessionId)) .limit(1); const row = rows[0]; if (!row) return { ok: false as const, error: "Session not found" }; if (row.session.expiresAt.getTime() < Date.now()) { // Best-effort cleanup await db.delete(waterSessions).where(eq(waterSessions.id, sessionId)); return { ok: false as const, error: "Session expired" }; } if (!row.irrigator.active) { return { ok: false as const, error: "User is inactive" }; } return { ok: true as const, userId: row.irrigator.id, brandId: row.irrigator.brandId, role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator", }; }); } async function getFieldSessionUser(): Promise<{ userId: string; name: string; brandId: string; role: "irrigator" | "water_admin"; } | null> { await getSession(); const s = await requireFieldSession(); if (!s.ok) return null; return withPlatformAdmin(async (db) => { const rows = await db .select({ name: waterIrrigators.name, role: waterIrrigators.role }) .from(waterIrrigators) .where(eq(waterIrrigators.id, s.userId)) .limit(1); const row = rows[0]; if (!row) return null; return { userId: s.userId, name: row.name, brandId: s.brandId, role: (row.role as "irrigator" | "water_admin") ?? "irrigator", }; }); } // ── Cookie/language helpers ─────────────────────────────────────────────── export async function logoutWater(): Promise { await getSession(); 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 { await getSession(); 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"); } async function getWaterSession(): Promise { await getSession(); const cookieStore = await cookies(); return cookieStore.get("wl_session")?.value ?? null; } /** * Resolves the current `/water/admin` session. * * Returns the admin role + brandId on success, or `null` if the cookie * is missing / expired / points at a deleted session row. Used by * admin pages (entries/[id], headgates/[id], users/[id]) as a *second* * gate on top of `getAdminUser()` — a site admin can edit entries * directly, but a PIN-authenticated water admin can too. */ export async function getWaterAdminSession(): Promise<{ sessionId: string; brandId: string; adminUserId: string; role: "water_admin"; } | null> { await getSession(); const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_admin_session")?.value; if (!sessionId) return null; return withPlatformAdmin(async (db) => { const rows = await db .select({ id: waterAdminSessions.id, brandId: waterAdminSessions.brandId, adminUserId: waterAdminSessions.adminUserId, expiresAt: waterAdminSessions.expiresAt, }) .from(waterAdminSessions) .where(eq(waterAdminSessions.id, sessionId)) .limit(1); const row = rows[0]; if (!row) return null; if (row.expiresAt.getTime() <= Date.now()) return null; return { sessionId: row.id, brandId: row.brandId, adminUserId: row.adminUserId, role: "water_admin" as const, }; }); } export async function setWaterLang(lang: string): Promise { if (!["en", "es"].includes(lang)) return; await getSession(); 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"); }