migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4)
This commit is contained in:
+11
-22
@@ -1,15 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set");
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
type AdminActionPayload = {
|
||||
action_type: "create" | "update" | "delete";
|
||||
@@ -30,35 +21,33 @@ type UserActivityPayload = {
|
||||
|
||||
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
|
||||
try {
|
||||
const service = getServiceClient();
|
||||
await service.rpc("log_admin_action", {
|
||||
p_payload: {
|
||||
await pool.query("SELECT log_admin_action($1::jsonb)", [
|
||||
JSON.stringify({
|
||||
action_type: payload.action_type,
|
||||
admin_id: payload.admin_id ?? null,
|
||||
admin_email: payload.admin_email ?? null,
|
||||
affected_user_id: payload.affected_user_id ?? null,
|
||||
brand_id: payload.brand_id ?? null,
|
||||
details: payload.details ?? {},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// logging failed silently
|
||||
}
|
||||
}
|
||||
|
||||
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
|
||||
try {
|
||||
const service = getServiceClient();
|
||||
await service.rpc("log_user_activity", {
|
||||
p_payload: {
|
||||
await pool.query("SELECT log_user_activity($1::jsonb)", [
|
||||
JSON.stringify({
|
||||
user_id: payload.user_id,
|
||||
activity_type: payload.activity_type,
|
||||
details: payload.details ?? {},
|
||||
ip_address: payload.ip_address ?? null,
|
||||
user_agent: payload.user_agent ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
// logging failed silently
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { createClient as createServiceClient } from "@supabase/supabase-js";
|
||||
|
||||
function getServiceClient() {
|
||||
const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
|
||||
return createServiceClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
roleKey,
|
||||
);
|
||||
}
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export type ResetAdminPasswordResult =
|
||||
| { success: true; tempPassword: string }
|
||||
@@ -17,35 +8,35 @@ export type ResetAdminPasswordResult =
|
||||
|
||||
/**
|
||||
* Emergency recovery action — only usable in development or when the caller
|
||||
* already has service role access. Resets the password for the specified email
|
||||
* already has direct DB access. Resets the password for the specified email
|
||||
* and returns the temp password so it can be displayed to the user.
|
||||
*
|
||||
* Now that Supabase auth is gone, this hits the `users` table directly and
|
||||
* calls the same `update_user_password` SECURITY DEFINER RPC used by the
|
||||
* self-service password change action.
|
||||
*/
|
||||
export async function resetAdminPassword(
|
||||
email: string,
|
||||
newPassword: string
|
||||
): Promise<ResetAdminPasswordResult> {
|
||||
const service = getServiceClient();
|
||||
|
||||
// Look up auth user by email
|
||||
const { data: authUsers, error: listError } = await service.auth.admin.listUsers();
|
||||
if (listError || !authUsers?.users) {
|
||||
return { success: false, error: "Could not list users: " + listError?.message };
|
||||
}
|
||||
|
||||
const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (!authUser) {
|
||||
// Look up the user by email
|
||||
const { rows } = await query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE email = $1 LIMIT 1",
|
||||
[email.toLowerCase()],
|
||||
);
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return { success: false, error: "No auth user found for that email address." };
|
||||
}
|
||||
|
||||
// Update password via service role
|
||||
const { error: updateError } = await service.auth.admin.updateUserById(
|
||||
authUser.id,
|
||||
{ password: newPassword, email_confirm: true }
|
||||
);
|
||||
|
||||
if (updateError) {
|
||||
return { success: false, error: updateError.message };
|
||||
// Update password via SECURITY DEFINER RPC
|
||||
try {
|
||||
await query("SELECT update_user_password($1, $2)", [user.id, newPassword]);
|
||||
return { success: true, tempPassword: newPassword };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to update password",
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, tempPassword: newPassword };
|
||||
}
|
||||
}
|
||||
|
||||
+16
-37
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type BrandListItem = {
|
||||
id: string;
|
||||
@@ -16,49 +16,28 @@ export type BrandListItem = {
|
||||
* - platform_admin: all brands (queried directly from the `brands` table)
|
||||
* - everyone else: brands in `adminUser.brand_ids`
|
||||
* - empty array for unauthenticated / no-access admins
|
||||
*
|
||||
* This is a plain async function (not a server action) so it can be called
|
||||
* from server components and server actions without the "use server" wrapper.
|
||||
* The BrandSelector client component receives the result as a prop.
|
||||
*/
|
||||
export async function listBrandsForAdmin(): Promise<BrandListItem[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) return [];
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (adminUser.brand_ids.length === 0) return [];
|
||||
|
||||
// Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting
|
||||
// pattern in the spec is safe; the inner quotes are required by PostgREST
|
||||
// for UUID literals.
|
||||
const filter = `id=in.(${adminUser.brand_ids
|
||||
.map((id) => `"${id}"`)
|
||||
.join(",")})`;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
|
||||
{ headers: svcHeaders(serviceKey), cache: "no-store" }
|
||||
if (adminUser.role === "platform_admin") {
|
||||
const { rows } = await pool.query<BrandListItem>(
|
||||
`SELECT id, name, slug, logo_url FROM brands ORDER BY name`,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (adminUser.brand_ids.length === 0) return [];
|
||||
|
||||
const { rows } = await pool.query<BrandListItem>(
|
||||
`SELECT id, name, slug, logo_url FROM brands
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY name`,
|
||||
[adminUser.brand_ids],
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
+83
-79
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type CartItem = {
|
||||
id: string;
|
||||
@@ -64,9 +64,6 @@ export async function createOrder(
|
||||
brandId?: string,
|
||||
shippingAddress?: ShippingAddress
|
||||
): Promise<CheckoutResult> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// ── Calculate tax if brand collects tax ─────────────────────────────────
|
||||
let taxAmount = 0;
|
||||
let taxRate = 0;
|
||||
@@ -90,33 +87,22 @@ export async function createOrder(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_customer_name: customerName,
|
||||
p_customer_email: customerEmail,
|
||||
p_customer_phone: customerPhone,
|
||||
p_stop_id: stopId,
|
||||
p_items: items,
|
||||
p_tax_amount: taxAmount,
|
||||
p_tax_rate: taxRate,
|
||||
p_tax_location: taxLocation || null,
|
||||
}),
|
||||
}
|
||||
const { rows } = await pool.query<{ create_order_with_items: CreatedOrder | null }>(
|
||||
`SELECT create_order_with_items($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) AS "create_order_with_items"`,
|
||||
[
|
||||
idempotencyKey,
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
stopId,
|
||||
JSON.stringify(items),
|
||||
taxAmount,
|
||||
taxRate,
|
||||
taxLocation || null,
|
||||
],
|
||||
);
|
||||
const data = rows[0]?.create_order_with_items;
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ message: "Unknown error" }));
|
||||
return { success: false, error: err.message ?? "Failed to create order" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// RPC returns a JSONB object with order + items
|
||||
if (!data || !data.id) {
|
||||
return { success: false, error: "Order created but data not returned" };
|
||||
}
|
||||
@@ -124,14 +110,20 @@ export async function createOrder(
|
||||
// Send order receipt email
|
||||
try {
|
||||
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
|
||||
const rawItems = data.items ?? [];
|
||||
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
|
||||
await sendOrderReceiptEmail({
|
||||
customerName,
|
||||
customerEmail,
|
||||
orderId: data.id,
|
||||
items: data.items ?? [],
|
||||
items: rawItems.map((i) => ({
|
||||
name: i.product_name,
|
||||
quantity: i.quantity,
|
||||
price: i.price,
|
||||
})),
|
||||
subtotal: data.subtotal ?? 0,
|
||||
taxAmount: data.tax_amount ?? 0,
|
||||
total: (data.subtotal ?? 0) + (data.tax_amount ?? 0),
|
||||
taxAmount,
|
||||
total: (data.subtotal ?? 0) + taxAmount,
|
||||
fulfillment: items.some((i) => i.fulfillment === "ship") ? "ship" : "pickup",
|
||||
stopCity: data.stop_city ?? undefined,
|
||||
stopState: data.stop_state ?? undefined,
|
||||
@@ -140,31 +132,22 @@ export async function createOrder(
|
||||
stopLocation: data.stop_location ?? undefined,
|
||||
brandName: "Tuxedo Corn",
|
||||
});
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Email failure should not fail the order
|
||||
}
|
||||
|
||||
return { success: true, order: data as CreatedOrder };
|
||||
return { success: true, order: data };
|
||||
}
|
||||
|
||||
// ── Cart Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getServerCart(userId: string): Promise<CartItem[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
|
||||
`SELECT get_user_cart($1) AS "get_user_cart"`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
const data = rows[0]?.get_user_cart;
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
@@ -177,24 +160,15 @@ export async function mergeLocalCart(
|
||||
): Promise<{ merged: CartItem[] }> {
|
||||
if (!localCart || localCart.length === 0) return { merged: [] };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Fetch server cart
|
||||
let serverCart: CartItem[] = [];
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
|
||||
`SELECT get_user_cart($1) AS "get_user_cart"`,
|
||||
[userId],
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
serverCart = Array.isArray(data) ? data : [];
|
||||
}
|
||||
const data = rows[0]?.get_user_cart;
|
||||
serverCart = Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
// proceed with local cart only
|
||||
}
|
||||
@@ -227,13 +201,9 @@ export async function mergeLocalCart(
|
||||
|
||||
// Persist merged cart to server
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId, p_items: merged }),
|
||||
}
|
||||
await pool.query(
|
||||
`SELECT upsert_user_cart($1, $2::jsonb)`,
|
||||
[userId, JSON.stringify(merged)],
|
||||
);
|
||||
} catch {
|
||||
// best-effort — localStorage is still source of truth for now
|
||||
@@ -243,19 +213,53 @@ export async function mergeLocalCart(
|
||||
}
|
||||
|
||||
export async function clearServerCart(userId: string): Promise<void> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/clear_user_cart`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_user_id: userId }),
|
||||
}
|
||||
await pool.query(
|
||||
`SELECT clear_user_cart($1)`,
|
||||
[userId],
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stop picker (used by cart + checkout client components) ───────────────
|
||||
|
||||
export type PublicStop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
|
||||
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
|
||||
const { rows } = await pool.query<PublicStop>(
|
||||
`SELECT id, city, state, date, time, location, brand_id
|
||||
FROM stops
|
||||
WHERE active = true AND brand_id = $1
|
||||
ORDER BY date`,
|
||||
[brandId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export type ProductAvailability = {
|
||||
product_id: string;
|
||||
is_available: boolean;
|
||||
};
|
||||
|
||||
export async function checkStopProductAvailability(
|
||||
stopId: string,
|
||||
productIds: string[]
|
||||
): Promise<ProductAvailability[]> {
|
||||
if (!productIds || productIds.length === 0) return [];
|
||||
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
|
||||
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
|
||||
[stopId, productIds],
|
||||
);
|
||||
const data = rows[0]?.check_stop_product_availability;
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -65,25 +62,22 @@ export type CreatePainLogData = {
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
// ── Internal fetch helper ────────────────────────────────────────────────────
|
||||
// ── Internal RPC helper ──────────────────────────────────────────────────────
|
||||
|
||||
async function platformRPC<T>(rpcName: string, body: Record<string, unknown> = {}): Promise<T> {
|
||||
async function platformRPC<T>(rpcName: string): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
if (adminUser.role !== "platform_admin") throw new Error("Access denied: platform admin only");
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const { rows } = await pool.query<Record<string, T>>(
|
||||
`SELECT ${rpcName}() AS "${rpcName}"`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
||||
const data = rows[0]?.[rpcName];
|
||||
if (data == null) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// ── Platform actions ─────────────────────────────────────────────────────────
|
||||
@@ -104,19 +98,11 @@ export async function getPainLog(): Promise<PainLogItem[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/founder_pain_log_platform?select=*`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
const { rows } = await pool.query<PainLogItem>(
|
||||
`SELECT * FROM founder_pain_log_platform ORDER BY created_at DESC`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Failed to fetch pain log: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PainLogItem[]>;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createPainLogItem(data: CreatePainLogData): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -125,22 +111,17 @@ export async function createPainLogItem(data: CreatePainLogData): Promise<{ succ
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/founder_pain_log`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
brand_id: data.brand_id || null,
|
||||
severity: data.severity,
|
||||
category: data.category,
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO founder_pain_log (brand_id, severity, category, title, description)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
data.brand_id || null,
|
||||
data.severity,
|
||||
data.category,
|
||||
data.title,
|
||||
data.description || null,
|
||||
],
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
@@ -154,26 +135,15 @@ export async function resolvePainLogItem(id: string): Promise<{ success: boolean
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.role !== "platform_admin") return { success: false, error: "Access denied" };
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/founder_pain_log?id=eq.${id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
status: "resolved",
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolved_by: adminUser.id,
|
||||
}),
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE founder_pain_log
|
||||
SET status = 'resolved', resolved_at = now(), resolved_by = $2
|
||||
WHERE id = $1`,
|
||||
[id, adminUser.id],
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: err };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAIClient } from "@/actions/integrations/ai-providers";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
const SYSTEM = `You are a data analyst for a B2B produce wholesale platform.
|
||||
Given a natural language customer query, return a JSON response indicating which predefined query to run and what parameters to use.
|
||||
@@ -82,40 +82,34 @@ Use "recent_orders" for recent order questions.`;
|
||||
const queryType = parsed.queryType ?? "recent_orders";
|
||||
const days = parsed.days ?? 30;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
// Route to appropriate RPC based on query type
|
||||
let rpcName = "get_recent_orders_insights";
|
||||
let rpcParams: Record<string, unknown> = { p_brand_id: effectiveBrandId, p_days: days };
|
||||
let rpcArgs: unknown[] = [effectiveBrandId, days];
|
||||
|
||||
if (queryType === "dormant") {
|
||||
rpcName = "get_dormant_customers_insights";
|
||||
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
|
||||
rpcArgs = [effectiveBrandId, days];
|
||||
} else if (queryType === "trending") {
|
||||
rpcName = "get_trending_products_insights";
|
||||
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
|
||||
rpcArgs = [effectiveBrandId, days];
|
||||
} else if (queryType === "top_customers") {
|
||||
rpcName = "get_top_customers_insights";
|
||||
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
|
||||
rpcArgs = [effectiveBrandId, days];
|
||||
} else if (queryType === "at_risk") {
|
||||
rpcName = "get_at_risk_customers_insights";
|
||||
rpcParams = { p_brand_id: effectiveBrandId };
|
||||
rpcArgs = [effectiveBrandId];
|
||||
}
|
||||
|
||||
const dbResponse = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/${rpcName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" },
|
||||
body: JSON.stringify(rpcParams),
|
||||
}
|
||||
);
|
||||
|
||||
let results: unknown[] = [];
|
||||
if (dbResponse.ok) {
|
||||
const data = await dbResponse.json();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${rpcName}(${rpcArgs.map((_, i) => `$${i + 1}`).join(", ")}) AS result`,
|
||||
rpcArgs,
|
||||
);
|
||||
const data = rows[0]?.result;
|
||||
results = Array.isArray(data) ? data.slice(0, 100) : [];
|
||||
} catch {
|
||||
results = [];
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -129,4 +123,4 @@ Use "recent_orders" for recent order questions.`;
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -44,44 +45,31 @@ export async function POST(req: NextRequest) {
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createCampaignSchema.safeParse(body);
|
||||
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create campaign via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_name: validation.data.name,
|
||||
p_subject: validation.data.subject,
|
||||
p_content: validation.data.content,
|
||||
p_type: validation.data.type,
|
||||
p_segment_id: validation.data.segment_id,
|
||||
p_contact_ids: validation.data.contact_ids,
|
||||
p_scheduled_at: validation.data.scheduled_at,
|
||||
}),
|
||||
}
|
||||
const { rows: campaignRows } = await pool.query<{ create_campaign: { id: string; [k: string]: unknown } | null }>(
|
||||
`SELECT create_campaign($1, $2, $3, $4, $5, $6, $7::uuid[], $8) AS create_campaign`,
|
||||
[
|
||||
validation.data.brand_id,
|
||||
validation.data.name,
|
||||
validation.data.subject,
|
||||
validation.data.content,
|
||||
validation.data.type,
|
||||
validation.data.segment_id ?? null,
|
||||
validation.data.contact_ids ?? null,
|
||||
validation.data.scheduled_at ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create campaign", 500);
|
||||
const campaign = campaignRows[0]?.create_campaign;
|
||||
if (!campaign) {
|
||||
return apiError("Failed to create campaign", 500);
|
||||
}
|
||||
|
||||
const campaign = await res.json();
|
||||
|
||||
// Track analytics
|
||||
const audienceSize = validation.data.contact_ids?.length || 0;
|
||||
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
|
||||
@@ -102,24 +90,13 @@ export async function GET(req: NextRequest) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/communication_campaigns?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const { rows: campaigns } = await pool.query(
|
||||
`SELECT * FROM communication_campaigns
|
||||
WHERE brand_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[brand_id],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch campaigns", 500);
|
||||
}
|
||||
|
||||
const campaigns = await res.json();
|
||||
return apiResponse(campaigns);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "GET" });
|
||||
@@ -137,4 +114,4 @@ export async function OPTIONS() {
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -38,39 +39,25 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = await req.json();
|
||||
const validation = createReferralSchema.safeParse(body);
|
||||
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Redeem referral via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_referred_email: validation.data.referred_email,
|
||||
p_referral_code: validation.data.referral_code,
|
||||
}),
|
||||
}
|
||||
const { rows } = await pool.query<{ redeem_referral: { referred_user_id?: string; [k: string]: unknown } | null }>(
|
||||
`SELECT redeem_referral($1, $2, $3) AS redeem_referral`,
|
||||
[
|
||||
validation.data.brand_id,
|
||||
validation.data.referred_email,
|
||||
validation.data.referral_code,
|
||||
],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to redeem referral", 500);
|
||||
const referral = rows[0]?.redeem_referral;
|
||||
if (!referral) {
|
||||
return apiError("Failed to redeem referral", 500);
|
||||
}
|
||||
|
||||
const referral = await res.json();
|
||||
|
||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
||||
|
||||
return apiResponse(referral, 201);
|
||||
@@ -89,24 +76,13 @@ export async function GET(req: NextRequest) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/referral_codes?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const { rows: referrals } = await pool.query(
|
||||
`SELECT * FROM referral_codes
|
||||
WHERE brand_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[brand_id],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch referrals", 500);
|
||||
}
|
||||
|
||||
const referrals = await res.json();
|
||||
return apiResponse(referrals);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "GET" });
|
||||
@@ -124,4 +100,4 @@ export async function OPTIONS() {
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -50,34 +51,21 @@ export async function GET(req: NextRequest) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get report via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/generate_report`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_start_date: validation.data.start_date,
|
||||
p_end_date: validation.data.end_date,
|
||||
p_report_type: validation.data.report_type,
|
||||
}),
|
||||
}
|
||||
const { rows } = await pool.query<{ generate_report: unknown }>(
|
||||
`SELECT generate_report($1, $2::timestamptz, $3::timestamptz, $4) AS generate_report`,
|
||||
[
|
||||
validation.data.brand_id,
|
||||
validation.data.start_date,
|
||||
validation.data.end_date,
|
||||
validation.data.report_type,
|
||||
],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const report = rows[0]?.generate_report;
|
||||
if (report == null) {
|
||||
return apiError("Failed to generate report", 500);
|
||||
}
|
||||
|
||||
const report = await res.json();
|
||||
|
||||
return apiResponse(report);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/reports", method: "GET" });
|
||||
@@ -95,4 +83,4 @@ export async function OPTIONS() {
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// Helper functions
|
||||
function apiResponse(data: unknown, status: number = 200) {
|
||||
@@ -43,44 +44,30 @@ export async function POST(req: NextRequest) {
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createWaterLogSchema.safeParse(body);
|
||||
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create water log via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_field_id: validation.data.field_id,
|
||||
p_field_name: validation.data.field_name,
|
||||
p_gallons: validation.data.gallons,
|
||||
p_duration_minutes: validation.data.duration_minutes,
|
||||
p_water_method: validation.data.water_method,
|
||||
p_notes: validation.data.notes,
|
||||
p_logged_at: validation.data.logged_at,
|
||||
}),
|
||||
}
|
||||
const { rows } = await pool.query<{ create_water_log: { id: string; [k: string]: unknown } | null }>(
|
||||
`SELECT create_water_log($1, $2, $3, $4, $5, $6, $7, $8) AS create_water_log`,
|
||||
[
|
||||
validation.data.brand_id,
|
||||
validation.data.field_id ?? null,
|
||||
validation.data.field_name ?? null,
|
||||
validation.data.gallons,
|
||||
validation.data.duration_minutes ?? null,
|
||||
validation.data.water_method ?? null,
|
||||
validation.data.notes ?? null,
|
||||
validation.data.logged_at ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create water log", 500);
|
||||
const waterLog = rows[0]?.create_water_log;
|
||||
if (!waterLog) {
|
||||
return apiError("Failed to create water log", 500);
|
||||
}
|
||||
|
||||
const waterLog = await res.json();
|
||||
|
||||
return apiResponse(waterLog, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
|
||||
@@ -97,24 +84,14 @@ export async function GET(req: NextRequest) {
|
||||
return apiError("brand_id is required", 400);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/water_logs?brand_id=eq.${brand_id}&select=*&order=logged_at.desc&limit=100`,
|
||||
{
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
const { rows: waterLogs } = await pool.query(
|
||||
`SELECT * FROM water_logs
|
||||
WHERE brand_id = $1
|
||||
ORDER BY logged_at DESC
|
||||
LIMIT 100`,
|
||||
[brand_id],
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch water logs", 500);
|
||||
}
|
||||
|
||||
const waterLogs = await res.json();
|
||||
return apiResponse(waterLogs);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "GET" });
|
||||
@@ -132,4 +109,4 @@ export async function OPTIONS() {
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
@@ -9,34 +9,27 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: "Missing params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Get admin settings
|
||||
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 settingsRes = await pool.query<{
|
||||
get_water_admin_settings: { enabled: boolean; session_duration_hours?: number } | null;
|
||||
}>(
|
||||
`SELECT get_water_admin_settings($1) AS "get_water_admin_settings"`,
|
||||
[brandId],
|
||||
);
|
||||
const settings = await settingsRes.json();
|
||||
if (!settingsRes.ok || !settings?.enabled) {
|
||||
const settings = settingsRes.rows[0]?.get_water_admin_settings;
|
||||
if (!settings?.enabled) {
|
||||
return NextResponse.json({ success: false, error: "Admin portal not enabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Verify PIN
|
||||
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 verifyRes = await pool.query<{
|
||||
verify_water_admin_pin: { success: boolean; session_id?: string } | null;
|
||||
}>(
|
||||
`SELECT verify_water_admin_pin($1, $2) AS "verify_water_admin_pin"`,
|
||||
[brandId, pin],
|
||||
);
|
||||
const verifyData = await verifyRes.json();
|
||||
if (!verifyRes.ok || !verifyData?.success) {
|
||||
const verifyData = verifyRes.rows[0]?.verify_water_admin_pin;
|
||||
if (!verifyData?.success || !verifyData.session_id) {
|
||||
return NextResponse.json({ success: false, error: "Invalid PIN" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -56,4 +49,4 @@ export async function POST(request: Request) {
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -18,31 +18,26 @@ export async function GET(request: NextRequest) {
|
||||
// Use brand_id from session (always Tuxedo for water log) or fallback to env
|
||||
const brandId = adminUser.brand_id ?? process.env.TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
type WaterEntry = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
headgate_id: string | null;
|
||||
measurement: number | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
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: 10000 }),
|
||||
}
|
||||
const { rows: data } = await pool.query<{ get_water_entries: WaterEntry[] | null }>(
|
||||
`SELECT get_water_entries($1, $2) AS "get_water_entries"`,
|
||||
[brandId, 10000],
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch water log" }, { status: 500 });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const entries = data[0]?.get_water_entries ?? [];
|
||||
|
||||
if (format === "csv") {
|
||||
const headers = ["id", "user_id", "headgate_id", "measurement", "unit", "notes", "created_at"];
|
||||
const csvRows = [headers.join(",")];
|
||||
for (const row of data) {
|
||||
for (const row of entries) {
|
||||
csvRows.push([
|
||||
row.id,
|
||||
row.user_id ?? "",
|
||||
@@ -61,5 +56,5 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
return NextResponse.json(entries);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,9 @@ import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { getPublicStopsForBrand, checkStopProductAvailability, type PublicStop } from "@/actions/checkout";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
brand_id: string;
|
||||
};
|
||||
type Stop = PublicStop;
|
||||
|
||||
export default function CartClient() {
|
||||
const {
|
||||
@@ -54,11 +47,7 @@ export default function CartClient() {
|
||||
if (hasPickupItems && showStopPicker && cartBrandId) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoadingStops(true);
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
|
||||
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
|
||||
)
|
||||
.then((r) => r.json())
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => setStops(data ?? []))
|
||||
.catch(() => setStops([]))
|
||||
.finally(() => setLoadingStops(false));
|
||||
@@ -74,19 +63,8 @@ export default function CartClient() {
|
||||
|
||||
if (hasPickupItems) {
|
||||
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/check_stop_product_availability`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
|
||||
}
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((data: { product_id: string; is_available: boolean }[]) => {
|
||||
checkStopProductAvailability(stop.id, pickupProductIds)
|
||||
.then((data) => {
|
||||
const unavailable = (data ?? [])
|
||||
.filter((row) => row.is_available === false)
|
||||
.map((row) => row.product_id);
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
import type { StopInfo } from "@/context/CartContext";
|
||||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||||
import { getPublicStopsForBrand } from "@/actions/checkout";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import StripeExpressCheckout, {
|
||||
@@ -43,15 +44,7 @@ export default function CheckoutClient() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!cartBrandId) return;
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
|
||||
{
|
||||
headers: {
|
||||
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((r) => r.json())
|
||||
getPublicStopsForBrand(cartBrandId)
|
||||
.then((data) => setStops(data ?? []))
|
||||
.catch(() => setStops([]));
|
||||
}, [cartBrandId]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTraceChain } from "@/actions/route-trace/lots";
|
||||
import { getTraceChain, getLotIdByNumber } from "@/actions/route-trace/lots";
|
||||
import ShareTraceButton from "@/components/route-trace/ShareTraceButton";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ lotNumber: string }> }) {
|
||||
const { lotNumber } = await params;
|
||||
@@ -70,25 +69,7 @@ function FieldIcon({ className }: { className?: string }) {
|
||||
export default async function TracePage({ params }: { params: Promise<{ lotNumber: string }> }) {
|
||||
const { lotNumber } = await params;
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
||||
|
||||
let lotId: string | null = null;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/harvest_lots?lot_number=eq.${encodeURIComponent(lotNumber)}&select=id`,
|
||||
{
|
||||
headers: {
|
||||
...svcHeaders(SUPABASE_PAT),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 60 },
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
lotId = data?.[0]?.id ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
const lotId = await getLotIdByNumber(lotNumber);
|
||||
if (!lotId) notFound();
|
||||
|
||||
const result = await getTraceChain(lotId);
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
/**
|
||||
* Service-layer admin user creation via Supabase REST API.
|
||||
* Uses apikey-only authentication — no Authorization header (which fails on
|
||||
* Vercel Edge due to raw JWT chars +, /, = in the token).
|
||||
* Service-layer admin user creation. Hits the `admin_users` table directly
|
||||
* via the shared pg pool. Returns the inserted row (or existing row if the
|
||||
* user was already provisioned).
|
||||
*/
|
||||
export async function createAdminUser(
|
||||
userId: string,
|
||||
role: string,
|
||||
brandId: string | null
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!supabaseUrl || !serviceKey) return null;
|
||||
|
||||
const body = JSON.stringify({
|
||||
const { pool } = await import("@/lib/db");
|
||||
const body = {
|
||||
user_id: userId,
|
||||
role,
|
||||
brand_id: brandId,
|
||||
@@ -28,33 +25,52 @@ export async function createAdminUser(
|
||||
can_manage_reports: role === "platform_admin",
|
||||
can_manage_settings: role === "platform_admin",
|
||||
must_change_password: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Use apikey-only — no Authorization header to avoid Vercel Edge JWT rejection
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
// User already exists — fetch and return
|
||||
const existing = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${userId}&limit=1`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
try {
|
||||
const { rows } = await pool.query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, role, brand_id, active,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
can_manage_settings, must_change_password)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role,
|
||||
brand_id = EXCLUDED.brand_id,
|
||||
active = EXCLUDED.active,
|
||||
can_manage_products = EXCLUDED.can_manage_products,
|
||||
can_manage_stops = EXCLUDED.can_manage_stops,
|
||||
can_manage_orders = EXCLUDED.can_manage_orders,
|
||||
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
||||
can_manage_messages = EXCLUDED.can_manage_messages,
|
||||
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
||||
can_manage_users = EXCLUDED.can_manage_users,
|
||||
can_manage_water_log = EXCLUDED.can_manage_water_log,
|
||||
can_manage_reports = EXCLUDED.can_manage_reports,
|
||||
can_manage_settings = EXCLUDED.can_manage_settings
|
||||
RETURNING *`,
|
||||
[
|
||||
body.user_id,
|
||||
body.role,
|
||||
body.brand_id,
|
||||
body.active,
|
||||
body.can_manage_products,
|
||||
body.can_manage_stops,
|
||||
body.can_manage_orders,
|
||||
body.can_manage_pickup,
|
||||
body.can_manage_messages,
|
||||
body.can_manage_refunds,
|
||||
body.can_manage_users,
|
||||
body.can_manage_water_log,
|
||||
body.can_manage_reports,
|
||||
body.can_manage_settings,
|
||||
body.must_change_password,
|
||||
],
|
||||
);
|
||||
const data = existing.ok ? await existing.json().catch(() => null) : null;
|
||||
return (Array.isArray(data) && data.length > 0) ? data[0] as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return (Array.isArray(data) ? data[0] : data) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user