Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s

Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.

Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log

Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns

Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages

API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers

UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac

Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates

Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment

Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
Tyler
2026-06-17 11:36:00 -06:00
parent 52c8a71cd0
commit 11cd2fd01a
22 changed files with 4944 additions and 1137 deletions
File diff suppressed because it is too large Load Diff
+399 -62
View File
@@ -1,88 +1,374 @@
/**
* 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";
// TODO(migration): the water-log field UI used a chain of Supabase RPCs
// (`get_water_headgates`, `verify_water_pin`, `get_water_user_by_id`,
// `submit_water_entry`, `trigger_water_alert`,
// `get_water_admin_session`) and tables (`water_headgates`,
// `water_users`, `water_sessions`, `water_admin_sessions`,
// `water_entries`, `water_alert_log`) that are not in the SaaS
// rebuild's `db/schema/`. The actions below preserve the original
// signatures and return empty / no-op responses so the field UI
// degrades gracefully. See `actions/route-trace/lots.ts` for the
// same pattern.
// 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";
type VerifyPinResult = {
success: true;
user_id: string;
name: string;
role: string;
session_id: string;
lang: string;
} | {
success: false;
error: string;
};
// ── Types ──────────────────────────────────────────────────────────────────
type SubmitEntryResult = {
success: true;
entry_id: string;
} | {
success: false;
error: string;
};
type Headgate = {
type FieldHeadgate = {
id: string;
name: string;
unit: string;
status: string;
high_threshold: number | null;
low_threshold: number | null;
active: boolean;
created_at: string;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
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 = false
): Promise<Headgate[]> {
return [];
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
pin: string,
): Promise<VerifyPinResult> {
return { success: false, error: NOT_CONFIGURED };
}
// Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
export async function submitWaterEntry(
_headgateId: string,
_measurement: number,
_unit: string,
_notes: string,
_photoUrl?: string,
_latitude?: number,
_longitude?: number,
_headgateLocked?: boolean
): Promise<SubmitEntryResult> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
// 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;
});
if (!sessionId) {
return { success: false, error: "Not logged in" };
// 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" };
}
return { success: false, error: NOT_CONFIGURED };
// Create a session in the user's brand context.
const sessionId = await withBrand(match.brandId, async (db) => {
const expiresAt = new Date(
Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000,
);
const [row] = await db
.insert(waterSessions)
.values({
irrigatorId: match.id,
expiresAt,
})
.returning({ id: waterSessions.id });
if (!row) throw new Error("Failed to create session");
// Bump last_used_at for "I haven't seen this person in a while" UX
await db
.update(waterIrrigators)
.set({ lastUsedAt: new Date() })
.where(eq(waterIrrigators.id, match.id));
return row.id;
});
const cookieStore = await 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 };
});
}
// ── Session helpers (shared with admin.ts via requireFieldSession) ────────
type FieldSession =
| { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" }
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
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",
};
});
}
export async function getFieldSessionUser(): Promise<{
userId: string;
name: string;
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
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> {
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");
}
@@ -91,7 +377,49 @@ export async function getWaterSession(): Promise<string | null> {
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> {
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;
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,
@@ -102,15 +430,24 @@ export async function setWaterLang(lang: string): Promise<void> {
});
}
export async function getWaterAdminSession(): Promise<{
user_id: string;
name: string;
role: string;
} | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
// ── 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");
}
+228 -28
View File
@@ -1,44 +1,244 @@
/**
* Water Log — admin settings (PIN-protected /water/admin portal).
*
* The `/water/admin` portal is gated by its own 4-digit PIN (separate
* from the irrigators' PIN). That PIN is *hashed* and stored in
* `water_admin_settings` (one row per brand). Sessions are tracked in
* `water_admin_sessions`.
*
* There is currently no per-admin-user PIN — a single brand-wide admin
* PIN. If you need per-user PINs (e.g. for an audit trail of which
* admin entered the portal), swap `pin_hash` for `admin_user_pins` and
* key sessions by `admin_user_id`.
*/
"use server";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import {
waterAdminSettings,
waterAdminSessions,
type WaterAdminSettings,
} from "@/db/schema/water-log";
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
// TODO(migration): the water-log settings RPCs (`get_water_admin_settings`,
// `hash_water_admin_pin`, `save_water_admin_settings`,
// `verify_water_admin_pin`) and the underlying
// `water_admin_settings` table are not in the SaaS rebuild schema.
// The functions below preserve the original signatures and return
// empty / no-op responses. Same pattern as
// `actions/route-trace/lots.ts`.
export type WaterAdminSettings = {
export type AdminSettings = {
enabled: boolean;
session_duration_hours: number;
can_edit_entries: boolean;
can_delete_entries: boolean;
can_export_csv: boolean;
alert_phone?: string | null;
alerts_enabled?: boolean;
sessionDurationHours: number;
canEditEntries: boolean;
canDeleteEntries: boolean;
canExportCsv: boolean;
alertPhone: string | null;
alertsEnabled: boolean;
/** The currently configured PIN, in plain text. Only returned on
* read right after the admin regenerates it; otherwise null. */
pin: string | null;
};
const NOT_CONFIGURED = "Water log is not configured in the SaaS rebuild";
function mapSettings(s: WaterAdminSettings): AdminSettings {
return {
enabled: s.enabled,
sessionDurationHours: s.sessionDurationHours,
canEditEntries: s.canEditEntries,
canDeleteEntries: s.canDeleteEntries,
canExportCsv: s.canExportCsv,
alertPhone: s.alertPhone,
alertsEnabled: s.alertsEnabled,
pin: null, // never returned unless just regenerated
};
}
export async function getWaterAdminSettings(_brandId: string): Promise<WaterAdminSettings | null> {
return null;
export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return null;
}
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
if (!rows[0]) {
// Lazy-initialize default settings the first time the page is hit
const [created] = await db
.insert(waterAdminSettings)
.values({ brandId })
.returning();
if (!created) return null;
return { ...mapSettings(created), pin: null };
}
return mapSettings(rows[0]);
});
}
export async function saveWaterAdminSettings(
_brandId: string,
_settings: Partial<WaterAdminSettings & { pin?: string }>
): Promise<{ success: boolean; error?: string }> {
brandId: string,
settings: Partial<AdminSettings>,
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_water_log) return { success: false, error: "Not authorized" };
return { success: false, error: NOT_CONFIGURED };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
if (
settings.sessionDurationHours != null &&
(settings.sessionDurationHours < 1 || settings.sessionDurationHours > 168)
) {
return { success: false, error: "Session duration must be 1168 hours" };
}
return withBrand(brandId, async (db) => {
const update: Partial<WaterAdminSettings> = {};
if (settings.enabled != null) update.enabled = settings.enabled;
if (settings.sessionDurationHours != null)
update.sessionDurationHours = settings.sessionDurationHours;
if (settings.canEditEntries != null)
update.canEditEntries = settings.canEditEntries;
if (settings.canDeleteEntries != null)
update.canDeleteEntries = settings.canDeleteEntries;
if (settings.canExportCsv != null)
update.canExportCsv = settings.canExportCsv;
if (settings.alertPhone !== undefined)
update.alertPhone = settings.alertPhone;
if (settings.alertsEnabled != null)
update.alertsEnabled = settings.alertsEnabled;
update.updatedBy = adminUser.user_id ?? adminUser.id ?? null;
// Upsert: insert if missing, update if present.
const updated = await db
.insert(waterAdminSettings)
.values({ brandId, ...update })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: update,
})
.returning();
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "update",
entityType: "settings",
details: { ...update },
});
return { success: true, settings: mapSettings(updated[0]) };
});
}
export async function verifyWaterAdminPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session_id?: string; error?: string }> {
return { success: false, error: NOT_CONFIGURED };
/**
* Generate a fresh PIN, hash it, and save it on the brand. Returns the
* plaintext PIN *once* — caller is responsible for showing it to the
* admin and never persisting it.
*/
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
adminUser.role !== "platform_admin"
) {
return { success: false, error: "Not authorized" };
}
const pin = generatePin();
const pinHash = hashPin(pin);
return withBrand(brandId, async (db) => {
await db
.insert(waterAdminSettings)
.values({ brandId, pinHash })
.onConflictDoUpdate({
target: waterAdminSettings.brandId,
set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null },
});
// Invalidate any existing admin sessions for safety
await db
.delete(waterAdminSessions)
.where(eq(waterAdminSessions.brandId, brandId));
await logAuditEvent({
brandId,
actorId: adminUser.user_id ?? null,
actorLabel: adminUser.email ?? adminUser.display_name ?? "admin",
action: "regenerate_admin_pin",
entityType: "settings",
});
return { success: true, pin };
});
}
/**
* PIN verify for the `/water/admin` portal. Creates a session row and
* sets the `wl_admin_session` cookie.
*/
export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
const rows = await db
.select()
.from(waterAdminSettings)
.where(eq(waterAdminSettings.brandId, brandId))
.limit(1);
const settings = rows[0];
if (!settings) {
return { success: false, error: "Admin portal not configured" };
}
if (!settings.enabled) {
return { success: false, error: "Admin portal is disabled" };
}
if (!settings.pinHash) {
return { success: false, error: "No PIN configured — generate one in /admin/water-log/settings" };
}
if (!verifyPin(pin, settings.pinHash)) {
await new Promise((r) => setTimeout(r, 200));
return { success: false, error: "Invalid PIN" };
}
// Tie the session to the calling site admin (best-effort).
const adminUser = await getAdminUser();
const expiresAt = new Date(
Date.now() + settings.sessionDurationHours * 60 * 60 * 1000,
);
const [session] = await db
.insert(waterAdminSessions)
.values({
brandId,
adminUserId: adminUser?.user_id ?? adminUser?.id ?? "00000000-0000-0000-0000-000000000000",
pinHashUsed: settings.pinHash,
expiresAt,
})
.returning({ id: waterAdminSessions.id });
if (!session) return { success: false, error: "Failed to create session" };
const cookieStore = await cookies();
cookieStore.set("wl_admin_session", session.id, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: settings.sessionDurationHours * 3600,
path: "/",
});
return { success: true, session_id: session.id };
});
}