Files
route-commerce/src/actions/water-log/field.ts
T
Tyler 658f6a5b8b
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
fix(water-log): remove platform-login dependency from PIN flows
Field users at /water were blocked because every server action called
getSession() (Neon Auth) even though the field path is PIN-authenticated.
A missing or expired platform session hung or rejected PIN submissions,
so users without a platform login could never reach the water log entry
screen.

Centralize water-log auth in src/actions/water-log/auth.ts with three
helpers — requireFieldSession, requireWaterAdminSession, and
requireWaterAdminPermission — and migrate all server actions off the
stray getSession() calls. Auth.ts deliberately does NOT import
getSession; a static-source unit test enforces that.

Changes:
- src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth
- src/actions/water-log/field.ts: -10 await getSession(), use helpers
- src/actions/water-log/admin.ts: -18 await getSession(), use helpers
- src/actions/water-log/settings.ts: -4 await getSession(), resilient
  to missing platform admin
- 3x admin pages: update getWaterAdminSession import path
- tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression
  guard that auth.ts never imports getSession
- tests/water-log.spec.ts: 3 E2E regression tests (no platform login)
- vitest.config.ts: alias for deep @/db/schema/water-log path

Rollback = revert this commit. No DB migration; no schema change.
Existing rows/cookies unchanged.
2026-07-01 17:27:37 -06:00

354 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<FieldHeadgate[]> {
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<VerifyPinResult> {
// 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<SubmitEntryResult> {
// ── 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<void> {
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<void> {
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<void> {
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");
}