Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
type Irrigator = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "irrigator" | "water_admin";
|
||||
active: boolean;
|
||||
language_preference: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
unit: string;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
headgate_token?: string | null;
|
||||
last_used_at?: string | null;
|
||||
high_threshold?: number | null;
|
||||
low_threshold?: number | null;
|
||||
};
|
||||
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
headgate_id: string;
|
||||
user_id: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
logged_at: string;
|
||||
headgate_unit?: string;
|
||||
};
|
||||
|
||||
// ── Headgate Admin ──────────────────────────────────────────
|
||||
|
||||
export async function createWaterHeadgate(brandId: string, name: string, unit: string = "CFS"): Promise<{ success: boolean; headgate?: Headgate; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_unit: unit }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.headgate) {
|
||||
return { success: false, error: data?.message ?? "Failed to create headgate" };
|
||||
}
|
||||
return { success: true, headgate: data.headgate };
|
||||
}
|
||||
|
||||
export async function updateWaterHeadgate(
|
||||
headgateId: string,
|
||||
name: string,
|
||||
active: boolean,
|
||||
unit?: string,
|
||||
highThreshold?: number | null,
|
||||
lowThreshold?: number | null
|
||||
): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_headgate_id: headgateId,
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
p_high_threshold: highThreshold ?? null,
|
||||
p_low_threshold: lowThreshold ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update headgate" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Irrigator Admin ─────────────────────────────────────────
|
||||
|
||||
export async function getWaterIrrigators(brandId: string): Promise<Irrigator[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_users`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.users ?? [];
|
||||
}
|
||||
|
||||
export async function createWaterIrrigator(
|
||||
brandId: string,
|
||||
name: string,
|
||||
lang: string = "en"
|
||||
): Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }> {
|
||||
return createWaterUser(brandId, name, "irrigator", lang) as Promise<{ success: boolean; irrigator?: Irrigator; pin?: string; error?: string }>;
|
||||
}
|
||||
|
||||
export async function createWaterUser(
|
||||
brandId: string,
|
||||
name: string,
|
||||
role: "irrigator" | "water_admin",
|
||||
lang: string = "en"
|
||||
): Promise<{ success: boolean; user?: Irrigator; pin?: string; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_name: name, p_role: role, p_lang: lang }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to create user" };
|
||||
}
|
||||
return { success: true, user: data.user, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function updateWaterIrrigator(
|
||||
irrigatorId: string,
|
||||
name: string,
|
||||
active: boolean,
|
||||
lang: string,
|
||||
role: "irrigator" | "water_admin"
|
||||
): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: irrigatorId,
|
||||
p_name: name,
|
||||
p_active: active,
|
||||
p_lang: lang,
|
||||
p_role: role,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update user" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function resetWaterIrrigatorPin(
|
||||
irrigatorId: 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) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/reset_water_user_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: irrigatorId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to reset PIN" };
|
||||
}
|
||||
return { success: true, pin: data.pin };
|
||||
}
|
||||
|
||||
export async function deleteWaterUser(userId: string): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_user_id: userId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete user" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWaterHeadgate(headgateId: string): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_headgate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_headgate_id: headgateId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete headgate" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Entries ────────────────────────────────────────────────
|
||||
|
||||
export async function getWaterEntries(brandId: string, limit = 50): Promise<WaterEntry[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_entries`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.entries ?? [];
|
||||
}
|
||||
|
||||
export async function getWaterHeadgatesAdmin(brandId: string): Promise<Headgate[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_headgates_admin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.headgates ?? [];
|
||||
}
|
||||
|
||||
export async function regenerateHeadgateToken(headgateId: string): Promise<{ success: boolean; token?: string; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/regenerate_headgate_token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_headgate_id: headgateId, p_brand_id: adminUser.brand_id ?? null }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to regenerate token" };
|
||||
}
|
||||
return { success: true, token: data.token };
|
||||
}
|
||||
|
||||
// ── Entry edit/delete ─────────────────────────────────────
|
||||
|
||||
export async function updateWaterEntry(
|
||||
entryId: string,
|
||||
measurement: number,
|
||||
notes: string | null,
|
||||
unit?: string
|
||||
): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_water_entry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_measurement: measurement,
|
||||
p_notes: notes,
|
||||
p_unit: unit ?? null,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to update entry" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWaterEntry(
|
||||
entryId: string
|
||||
): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_water_entry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_entry_id: entryId,
|
||||
p_brand_id: adminUser.brand_id ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete entry" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getWaterEntryById(entryId: string): Promise<WaterEntry | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_entry_by_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_entry_id: entryId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.entry ?? null;
|
||||
}
|
||||
|
||||
// ── Display summary ───────────────────────────────────
|
||||
|
||||
export type WaterDisplayEntry = {
|
||||
logged_at: string;
|
||||
headgate_name: string;
|
||||
user_name: string;
|
||||
user_role: string;
|
||||
measurement: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
submitted_via: string;
|
||||
};
|
||||
|
||||
export type WaterDisplayHeadgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
latest_entry: { measurement: number; user_name: string; logged_at: string } | null;
|
||||
last_logged_at: string | null;
|
||||
minutes_ago: number | null;
|
||||
};
|
||||
|
||||
export type WaterDisplaySummary = {
|
||||
headgates: WaterDisplayHeadgate[];
|
||||
today_count: number;
|
||||
today_total: number;
|
||||
recent_entries: WaterDisplayEntry[];
|
||||
};
|
||||
|
||||
export async function getWaterDisplaySummary(brandId: string): Promise<WaterDisplaySummary | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_display_summary`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as WaterDisplaySummary;
|
||||
}
|
||||
|
||||
export type AlertLogEntry = {
|
||||
id: string;
|
||||
alert_type: "high" | "low";
|
||||
threshold_value: number;
|
||||
reading_value: number;
|
||||
message_sent: string | null;
|
||||
sent_at: string;
|
||||
created_at: string;
|
||||
headgate_name: string;
|
||||
formatted_time: string;
|
||||
};
|
||||
|
||||
export async function getWaterAlertLog(brandId: string, limit = 50): Promise<AlertLogEntry[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_alert_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_limit: limit }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data?.alerts ?? [];
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"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_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_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_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_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_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_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_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_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;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export type WaterAdminSettings = {
|
||||
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;
|
||||
};
|
||||
|
||||
export async function getWaterAdminSettings(brandId: string): Promise<WaterAdminSettings | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data as WaterAdminSettings;
|
||||
}
|
||||
|
||||
export async function saveWaterAdminSettings(
|
||||
brandId: string,
|
||||
settings: Partial<WaterAdminSettings & { pin?: string }>
|
||||
): Promise<{ success: boolean; 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" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
let pinHash: string | null = null;
|
||||
if (settings.pin) {
|
||||
// Hash the PIN server-side using PostgreSQL
|
||||
const hashRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/hash_water_admin_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_pin: settings.pin }),
|
||||
}
|
||||
);
|
||||
const hashData = await hashRes.json();
|
||||
if (!hashData?.hash) return { success: false, error: "Failed to hash PIN" };
|
||||
pinHash = hashData.hash;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/save_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_enabled: settings.enabled ?? null,
|
||||
p_pin_hash: pinHash,
|
||||
p_session_duration_hours: settings.session_duration_hours ?? null,
|
||||
p_can_edit_entries: settings.can_edit_entries ?? null,
|
||||
p_can_delete_entries: settings.can_delete_entries ?? null,
|
||||
p_can_export_csv: settings.can_export_csv ?? null,
|
||||
p_alert_phone: settings.alert_phone ?? null,
|
||||
p_alerts_enabled: settings.alerts_enabled ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to save settings" };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function verifyWaterAdminPin(
|
||||
brandId: string,
|
||||
pin: string
|
||||
): Promise<{ success: boolean; session_id?: string; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const settingsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_water_admin_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
const settings = await settingsRes.json();
|
||||
if (!settingsRes.ok || !settings?.enabled) {
|
||||
return { success: false, error: "Admin portal not enabled" };
|
||||
}
|
||||
|
||||
// Verify PIN against stored hash
|
||||
const verifyRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/verify_water_admin_pin`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_pin: pin }),
|
||||
}
|
||||
);
|
||||
const verifyData = await verifyRes.json();
|
||||
if (!verifyRes.ok || !verifyData?.success) {
|
||||
return { success: false, error: "Invalid PIN" };
|
||||
}
|
||||
|
||||
return { success: true, session_id: verifyData.session_id };
|
||||
}
|
||||
Reference in New Issue
Block a user