migrate: replace Supabase REST with Drizzle/pg in water-log/time-tracking/reports/etc (wave 4)

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