Files
route-commerce/src/actions/water-log/field.ts
T
tyler d892b3f64f
Deploy to route.crispygoat.com / deploy (push) Failing after 9s
feat(selfhost): complete Supabase removal migration
Phase 1 — Pattern library
- Add src/lib/api.ts (typed PostgREST client, anon-key)
- Add src/lib/svc-fetch.ts (service-role fetch + PostgREST client)
- Add src/lib/db-types.ts (Database generic, RowOf helper)
- Add src/lib/auth-admin.ts (better-auth admin wrappers: createUser, listUsers, removeUser, requestPasswordReset, validateSession)

Phase 2 — Server action migration
- Migrate all 67 server actions off @supabase/supabase-js to svcApi/svcRpc
- Replace service.auth.admin.* with authAdmin.* (better-auth)
- Replace publicApi.auth.* with authAdmin.* (better-auth)
- Add typed single() null guards + nullable column fallbacks

Phase 3 — Delete mock data + debug routes
- Remove if(useMockData) branches from 7 files (-226 lines)
- Delete src/lib/mock-data.ts (no longer referenced)
- Delete 7 debug API routes (debug-admin-users, debug-auth, debug-cookie, debug-env, debug-get-admin-users, debug-hello, debug-me)

Phase 4 — Hard cut
- Delete src/lib/supabase.ts (orphan — no importers)
- Delete src/app/api/supabase + src/app/api/supabase-test routes
- Remove @supabase/ssr + @supabase/supabase-js from package.json
- Rename env vars: NEXT_PUBLIC_SUPABASE_URL → NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY → NEXT_PUBLIC_API_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY → POSTGREST_SERVICE_KEY

Phase 5 — Verify
- npx tsc --noEmit: 0 errors
- npm run lint: 0 new errors from migration (104 pre-existing errors unrelated)
- npm run build: same failure mode as main worktree (PostgREST-dependent prerender), no regression

Net: 158 files changed, +1640 / -1903 lines (-263 net)
2026-06-05 20:17:02 +00:00

255 lines
7.0 KiB
TypeScript

"use server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
type VerifyPinResult = {
success: true;
user_id: string;
name: string;
role: string;
session_id: string;
lang: string;
} | {
success: false;
error: string;
};
type SubmitEntryResult = {
success: true;
entry_id: string;
} | {
success: false;
error: string;
};
type Headgate = {
id: string;
name: string;
active: boolean;
created_at: string;
};
type HeadgatesResult = {
headgates: Headgate[];
};
export async function getWaterHeadgates(brandId: string, activeOnly = false): Promise<Headgate[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_headgates`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_active_only: activeOnly }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data?.headgates ?? [];
}
export async function verifyWaterPin(brandId: string, pin: string): Promise<VerifyPinResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/verify_water_pin`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
}
);
const data = await response.json();
if (!response.ok || !data?.success) {
return { success: false, error: data?.error ?? "Invalid PIN" };
}
// Get user's language preference via SECURITY DEFINER RPC (avoids direct table access)
const userResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_user_by_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_user_id: data.user_id }),
}
);
const userData = await userResponse.json();
const lang = userData?.language_preference ?? "en";
// Use the session already created by verify_water_pin RPC
const sessionId = data.session_id;
if (!sessionId) {
return { success: false, error: "Failed to create session" };
}
// Set HTTP-only session cookie
const cookieStore = await cookies();
cookieStore.set("wl_session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 4 * 60 * 60, // 4 hours
path: "/",
});
cookieStore.set("wl_lang", lang, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
return {
success: true,
user_id: data.user_id,
name: data.name,
role: data.role,
session_id: sessionId,
lang,
};
}
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;
if (!sessionId) {
return { success: false, error: "Not logged in" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/submit_water_entry`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_session_id: sessionId,
p_headgate_id: headgateId,
p_measurement: measurement,
p_unit: unit,
p_notes: notes,
p_submitted_via: "field",
p_photo_url: photoUrl ?? null,
p_latitude: latitude ?? null,
p_longitude: longitude ?? null,
p_headgate_locked: headgateLocked ?? false,
}),
}
);
const data = await response.json();
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to submit entry" };
}
const entryId = data.entry_id as string;
// ── Alert check (fire-and-forget, non-blocking) ─────────────────
try {
const alertRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_alert_type: "high",
p_threshold_value: measurement,
p_reading_value: measurement,
}),
}
);
const highData = await alertRes.json();
// If not triggered as high, try low
if (!highData?.triggered) {
await fetch(
`${supabaseUrl}/rest/v1/rpc/trigger_water_alert`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_entry_id: entryId,
p_alert_type: "low",
p_threshold_value: measurement,
p_reading_value: measurement,
}),
}
);
}
} catch {
// Alert failures should not affect entry submission success
}
return { success: true, entry_id: entryId };
}
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete("wl_session");
}
export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete("wl_admin_session");
}
export async function getWaterSession(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get("wl_session")?.value ?? null;
}
export async function setWaterLang(lang: string): Promise<void> {
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: "/",
});
}
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;
const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Use get_water_admin_session RPC (SECURITY DEFINER) to avoid direct water_sessions + water_users JOIN
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_admin_session`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_session_id: sessionId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data;
};