Files
route-commerce/src/actions/water-log/field.ts
T

471 lines
15 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 { 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<FieldHeadgate[]> {
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<VerifyPinResult> {
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<SubmitEntryResult> {
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<FieldSession> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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");
}