658f6a5b8b
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
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.
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
/**
|
||
* 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");
|
||
}
|