)
- .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
- .join("&");
- if (qs) url += `?${qs}`;
- }
-
- // Determine which key to use — prefer ANON_KEY for client-facing reads
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-
- const fetchOptions: RequestInit = {
- method: method.toUpperCase(),
- headers: {
- ...svcHeaders(supabaseKey),
- "Content-Type": "application/json",
- ...extraHeaders,
- },
- };
-
- if (body && !["GET", "HEAD"].includes(method.toUpperCase())) {
- fetchOptions.body = JSON.stringify(body);
- }
-
- const res = await fetch(url, fetchOptions);
- const data = await res.json().catch(() => null);
-
- return NextResponse.json(data, { status: res.status });
- } catch (err) {
- const msg = err instanceof Error ? err.message : "Proxy error";
- return NextResponse.json({ error: msg }, { status: 500 });
- }
-}
\ No newline at end of file
diff --git a/src/app/api/tuxedo/schedule-pdf/route.ts b/src/app/api/tuxedo/schedule-pdf/route.ts
index 486ee57..933d5d7 100644
--- a/src/app/api/tuxedo/schedule-pdf/route.ts
+++ b/src/app/api/tuxedo/schedule-pdf/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
type Settings = {
logo_url: string | null;
@@ -13,7 +13,7 @@ type Settings = {
export async function GET() {
const brandSlug = "tuxedo";
- const { data: brand } = await supabase
+ const { data: brand } = await api
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
@@ -23,14 +23,14 @@ export async function GET() {
return new NextResponse("Brand not found", { status: 404 });
}
- const { data: stops } = await supabase
+ const { data: stops } = (await api
.from("stops")
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
- .order("date", { ascending: true });
+ .order("date", { ascending: true })) as { data: Array<{ city: string; state: string; date: string; time: string; location: string }> | null };
- const { data: settings } = await supabase
+ const { data: settings } = await api
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
diff --git a/src/app/api/v1/campaigns/route.ts b/src/app/api/v1/campaigns/route.ts
index a536a50..0a29567 100644
--- a/src/app/api/v1/campaigns/route.ts
+++ b/src/app/api/v1/campaigns/route.ts
@@ -49,8 +49,8 @@ export async function POST(req: NextRequest) {
return validationError(validation.error);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
// Create campaign via RPC
const res = await fetch(
@@ -102,8 +102,8 @@ 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 supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/communication_campaigns?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
diff --git a/src/app/api/v1/products/route.ts b/src/app/api/v1/products/route.ts
index 15f13bc..3a9c7e7 100644
--- a/src/app/api/v1/products/route.ts
+++ b/src/app/api/v1/products/route.ts
@@ -55,8 +55,8 @@ export async function GET(req: NextRequest) {
return validationError(validation.error);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Build query
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
@@ -114,8 +114,8 @@ export async function POST(req: NextRequest) {
return apiError("brand_id and name are required", 400);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/products`,
diff --git a/src/app/api/v1/referrals/route.ts b/src/app/api/v1/referrals/route.ts
index 8b609c1..31fe9ce 100644
--- a/src/app/api/v1/referrals/route.ts
+++ b/src/app/api/v1/referrals/route.ts
@@ -43,8 +43,8 @@ export async function POST(req: NextRequest) {
return validationError(validation.error);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
// Redeem referral via RPC
const res = await fetch(
@@ -89,8 +89,8 @@ 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 supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/referral_codes?brand_id=eq.${brand_id}&select=*&order=created_at.desc`,
diff --git a/src/app/api/v1/reports/route.ts b/src/app/api/v1/reports/route.ts
index 4111610..4417273 100644
--- a/src/app/api/v1/reports/route.ts
+++ b/src/app/api/v1/reports/route.ts
@@ -50,8 +50,8 @@ 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!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
// Get report via RPC
const res = await fetch(
diff --git a/src/app/api/v1/water-logs/route.ts b/src/app/api/v1/water-logs/route.ts
index bc7c742..5aea3e3 100644
--- a/src/app/api/v1/water-logs/route.ts
+++ b/src/app/api/v1/water-logs/route.ts
@@ -48,8 +48,8 @@ export async function POST(req: NextRequest) {
return validationError(validation.error);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
// Create water log via RPC
const res = await fetch(
@@ -97,8 +97,8 @@ 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 supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const anonKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/water_logs?brand_id=eq.${brand_id}&select=*&order=logged_at.desc&limit=100`,
diff --git a/src/app/api/water-admin-auth/route.ts b/src/app/api/water-admin-auth/route.ts
index 2ae739d..a5132e3 100644
--- a/src/app/api/water-admin-auth/route.ts
+++ b/src/app/api/water-admin-auth/route.ts
@@ -9,8 +9,8 @@ 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!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// Get admin settings
const settingsRes = await fetch(
diff --git a/src/app/api/water-logs/export/route.ts b/src/app/api/water-logs/export/route.ts
index 22be0b0..a45d1c2 100644
--- a/src/app/api/water-logs/export/route.ts
+++ b/src/app/api/water-logs/export/route.ts
@@ -18,8 +18,8 @@ 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!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_water_entries`,
diff --git a/src/app/api/water-photo-upload/route.ts b/src/app/api/water-photo-upload/route.ts
index 0b675ae..d24b270 100644
--- a/src/app/api/water-photo-upload/route.ts
+++ b/src/app/api/water-photo-upload/route.ts
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
-import { svcHeaders } from "@/lib/svc-headers";
+import { uploadFile, publicUrl, BUCKETS } from "@/lib/storage";
+
+const ALLOWED_BUCKETS = Object.values(BUCKETS);
export async function POST(request: Request) {
try {
- // Validate session — irrigators use wl_session, admins use wl_admin_session
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
if (!sessionId) {
@@ -19,6 +20,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
}
+ if (!ALLOWED_BUCKETS.includes(bucket as (typeof ALLOWED_BUCKETS)[number])) {
+ return NextResponse.json({ error: "Invalid bucket" }, { status: 400 });
+ }
+
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
}
@@ -27,35 +32,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabasePat = process.env.SUPABASE_PAT!;
-
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
- const uint8 = new Uint8Array(arrayBuffer);
+ const buffer = Buffer.from(arrayBuffer);
- const uploadRes = await fetch(
- `${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
- {
- method: "POST",
- headers: {
- ...svcHeaders(supabasePat),
- "Content-Type": file.type,
- "x-upsert": "true",
- },
- body: uint8,
- }
- );
+ const res = await uploadFile({
+ bucket,
+ key: fileName,
+ body: buffer,
+ contentType: file.type,
+ });
- if (!uploadRes.ok) {
- return NextResponse.json({ error: "Upload failed" }, { status: 500 });
- }
-
- const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
- return NextResponse.json({ url: publicUrl });
+ return NextResponse.json({ url: res.url });
} catch (err) {
- return NextResponse.json({ error: "Server error" }, { status: 500 });
+ return NextResponse.json({ error: (err as Error).message || "Server error" }, { status: 500 });
}
-}
\ No newline at end of file
+}
diff --git a/src/app/api/wholesale/checkout/route.ts b/src/app/api/wholesale/checkout/route.ts
index a84f185..a0a2751 100644
--- a/src/app/api/wholesale/checkout/route.ts
+++ b/src/app/api/wholesale/checkout/route.ts
@@ -9,8 +9,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "orderId and customerId are required" }, { status: 400 });
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── 1. Fetch order and brand info ──────────────────────────────────────────
// Use direct select with both orderId AND customerId filters to prevent cross-brand access
diff --git a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts
index b617baf..95877ac 100644
--- a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts
+++ b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts
@@ -11,8 +11,8 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
// ── Token-based customer download ────────────────────────────────────────────
if (token) {
diff --git a/src/app/api/wholesale/invoice/[orderId]/route.ts b/src/app/api/wholesale/invoice/[orderId]/route.ts
index 4df205f..b0e7c60 100644
--- a/src/app/api/wholesale/invoice/[orderId]/route.ts
+++ b/src/app/api/wholesale/invoice/[orderId]/route.ts
@@ -9,8 +9,8 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
return new NextResponse("Server misconfiguration", { status: 500 });
diff --git a/src/app/api/wholesale/notifications/pickup-reminder/route.ts b/src/app/api/wholesale/notifications/pickup-reminder/route.ts
index cfcf418..892ebcf 100644
--- a/src/app/api/wholesale/notifications/pickup-reminder/route.ts
+++ b/src/app/api/wholesale/notifications/pickup-reminder/route.ts
@@ -9,8 +9,8 @@ import { svcHeaders } from "@/lib/svc-headers";
export const dynamic = "force-dynamic";
export async function POST() {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Find orders: fulfilled status, past anticipated pickup date, not yet picked up
const ordersRes = await fetch(
diff --git a/src/app/api/wholesale/notifications/send/route.ts b/src/app/api/wholesale/notifications/send/route.ts
index e936f3d..33a65e5 100644
--- a/src/app/api/wholesale/notifications/send/route.ts
+++ b/src/app/api/wholesale/notifications/send/route.ts
@@ -18,8 +18,8 @@ export async function POST() {
);
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
const pendingRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
@@ -180,7 +180,7 @@ async function sendOneEmail(
async function triggerPickupReminder() {
try {
- await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, {
+ await fetch(`${process.env.NEXT_PUBLIC_API_URL!}/api/wholesale/notifications/pickup-reminder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
diff --git a/src/app/api/wholesale/price-sheet/route.ts b/src/app/api/wholesale/price-sheet/route.ts
index 3bca08e..699b062 100644
--- a/src/app/api/wholesale/price-sheet/route.ts
+++ b/src/app/api/wholesale/price-sheet/route.ts
@@ -39,8 +39,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "customerIds array and brandId are required" }, { status: 400 });
}
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const supabaseKey = process.env.POSTGREST_SERVICE_KEY!;
// Fetch brand settings for branding + pickup location
const settings = await getWholesaleSettings(effectiveBrandId);
@@ -179,7 +179,7 @@ ${hasCustomNote ? ` {});
diff --git a/src/app/api/wholesale/webhooks/dispatch/route.ts b/src/app/api/wholesale/webhooks/dispatch/route.ts
index bf6cfce..c1912e6 100644
--- a/src/app/api/wholesale/webhooks/dispatch/route.ts
+++ b/src/app/api/wholesale/webhooks/dispatch/route.ts
@@ -6,8 +6,8 @@ import { svcHeaders } from "@/lib/svc-headers";
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
export async function POST() {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceRoleKey = process.env.POSTGREST_SERVICE_KEY!;
if (!supabaseUrl || !serviceRoleKey) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
diff --git a/src/app/auth/callback/page.tsx b/src/app/auth/callback/page.tsx
index 19ad3b4..5cfa77b 100644
--- a/src/app/auth/callback/page.tsx
+++ b/src/app/auth/callback/page.tsx
@@ -24,9 +24,9 @@ export default function AuthCallback() {
}
// Validate token by fetching user info from Supabase
- fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/user`, {
+ fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/v1/user`, {
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
Authorization: `Bearer ${accessToken}`,
},
})
diff --git a/src/app/cart/CartClient.tsx b/src/app/cart/CartClient.tsx
index 8ece8bc..8885f84 100644
--- a/src/app/cart/CartClient.tsx
+++ b/src/app/cart/CartClient.tsx
@@ -55,8 +55,8 @@ export default function CartClient() {
// 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! } }
+ `${process.env.NEXT_PUBLIC_API_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_API_ANON_KEY! } }
)
.then((r) => r.json())
.then((data) => setStops(data ?? []))
@@ -75,11 +75,11 @@ 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`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/check_stop_product_availability`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
diff --git a/src/app/checkout/CheckoutClient.tsx b/src/app/checkout/CheckoutClient.tsx
index 41c87c0..31f6ce6 100644
--- a/src/app/checkout/CheckoutClient.tsx
+++ b/src/app/checkout/CheckoutClient.tsx
@@ -33,10 +33,10 @@ 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,location,brand_id&order=date`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`,
{
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
}
)
diff --git a/src/app/indian-river-direct/contact/ContactClientPage.tsx b/src/app/indian-river-direct/contact/ContactClientPage.tsx
index 5cc59f5..07d78d5 100644
--- a/src/app/indian-river-direct/contact/ContactClientPage.tsx
+++ b/src/app/indian-river-direct/contact/ContactClientPage.tsx
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
type BrandSettings = {
invoice_business_name: string | null;
@@ -34,12 +34,12 @@ export default function IndianRiverContactPage() {
const [contactPhone, setContactPhone] = useState(null);
useEffect(() => {
- supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
+ api.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
if (!brand?.id) return;
- supabase.from("wholesale_settings")
+ api.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings(data ?? null));
- supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
+ api.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
.eq("brand_id", brand.id).single().then(({ data: s }) => {
if (s) { setLogoUrl(s.logo_url ?? null); setCustomFooterText(s.custom_footer_text ?? null); setContactEmail(s.email ?? null); setContactPhone(s.phone ?? null); }
});
diff --git a/src/app/indian-river-direct/faq/FAQClientPage.tsx b/src/app/indian-river-direct/faq/FAQClientPage.tsx
index a14c89a..eb8086e 100644
--- a/src/app/indian-river-direct/faq/FAQClientPage.tsx
+++ b/src/app/indian-river-direct/faq/FAQClientPage.tsx
@@ -6,7 +6,7 @@ import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
type FAQCategory = {
category: string;
@@ -136,7 +136,7 @@ export default function IndianRiverFAQPage() {
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
- supabase.from("brand_settings").select("phone").eq("brand_id",
+ api.from("brand_settings").select("phone").eq("brand_id",
"b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"
).single().then(({ data }) => {
if (data?.phone) setFaqCategories(buildFaqCategories(data.phone));
diff --git a/src/app/indian-river-direct/page.tsx b/src/app/indian-river-direct/page.tsx
index 56aa3ec..62636d3 100644
--- a/src/app/indian-river-direct/page.tsx
+++ b/src/app/indian-river-direct/page.tsx
@@ -8,7 +8,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
type Brand = { id: string; name: string; slug: string };
@@ -86,7 +86,7 @@ export default function IndianRiverDirectPage() {
async function load() {
const slug = "indian-river-direct";
const [brandResult, settingsResult] = await Promise.all([
- supabase.from("brands").select("*").eq("slug", slug).single(),
+ api.from("brands").select("*").eq("slug", slug).single(),
getBrandSettingsPublic(slug),
]);
@@ -110,10 +110,10 @@ export default function IndianRiverDirectPage() {
} catch { /* not logged in */ }
if (brandData?.id) {
- const [{ data: stopsData }, { data: productsData }] = await Promise.all([
- supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
- supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
- ]);
+ const [{ data: stopsData }, { data: productsData }] = (await Promise.all([
+ api.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
+ api.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
+ ])) as any;
setStops(stopsData ?? []);
setProducts(productsData ?? []);
}
diff --git a/src/app/indian-river-direct/stops/[slug]/page.tsx b/src/app/indian-river-direct/stops/[slug]/page.tsx
index c13a59c..ac450da 100644
--- a/src/app/indian-river-direct/stops/[slug]/page.tsx
+++ b/src/app/indian-river-direct/stops/[slug]/page.tsx
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
import { formatDate } from "@/lib/format-date";
type Product = {
@@ -43,12 +43,12 @@ export default function StopPage() {
useEffect(() => {
async function load() {
- const { data: stopData } = await supabase
+ const { data: stopData } = (await api
.from("stops")
.select("*")
.eq("slug", slug)
.eq("active", true)
- .single();
+ .single()) as any;
if (!stopData) return;
@@ -57,11 +57,11 @@ export default function StopPage() {
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green");
- const { data: productsData } = await supabase
+ const { data: productsData } = (await api
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
- .eq("active", true);
+ .eq("active", true)) as any;
setProducts(productsData ?? []);
}
load();
diff --git a/src/app/indian-river-direct/stops/page.tsx b/src/app/indian-river-direct/stops/page.tsx
index d48e4c3..c1b228e 100644
--- a/src/app/indian-river-direct/stops/page.tsx
+++ b/src/app/indian-river-direct/stops/page.tsx
@@ -5,6 +5,7 @@ import IndianRiverStopsList from "./IndianRiverStopsList";
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
// Mutations call revalidateTag("stops") to invalidate.
export const revalidate = 300;
+export const dynamic = "force-dynamic";
export default async function IndianRiverStopsPage() {
const [stops, settings] = await Promise.all([
diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx
index a2dea5f..be9bbe1 100644
--- a/src/app/logout/page.tsx
+++ b/src/app/logout/page.tsx
@@ -2,30 +2,24 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
-import { supabase } from "@/lib/supabase";
+import { authClient } from "@/lib/auth-client";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
- // Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
+ // Clear all auth cookies — dev_session, rc_session_token
document.cookie = "dev_session=;path=/;max-age=0";
- document.cookie = "rc_auth_uid=;path=/;max-age=0";
- document.cookie = "rc_auth_token=;path=/;max-age=0";
+ document.cookie = "rc_session_token=;path=/;max-age=0";
+ document.cookie = "wholesale_session=;path=/;max-age=0";
// Clear shopping cart on logout
localStorage.removeItem("route_commerce_cart");
localStorage.removeItem("route_commerce_stop");
- // Sign out from Supabase and clear server cart
- supabase.auth.getUser().then(async ({ data }) => {
- if (data.user?.id) {
- const { clearServerCart } = await import("@/actions/checkout");
- clearServerCart(data.user.id).catch(() => {});
- }
- supabase.auth.signOut().then(() => {
- router.push("/login");
- router.refresh();
- });
+ // Sign out from Better Auth
+ authClient.signOut().then(() => {
+ router.push("/login");
+ router.refresh();
});
}, [router]);
diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx
index 249694c..a65e159 100644
--- a/src/app/reset-password/page.tsx
+++ b/src/app/reset-password/page.tsx
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { supabase } from "@/lib/supabase";
+import { authClient } from "@/lib/auth-client";
export default function ResetPasswordPage() {
const router = useRouter();
@@ -28,22 +28,21 @@ export default function ResetPasswordPage() {
setLoading(true);
- const { error: authError } = await supabase.auth.updateUser({
- password,
+ const { error: authError } = await authClient.changePassword({
+ newPassword: password,
+ currentPassword: "", // user is coming from a recovery link; Better Auth requires a session
});
if (authError) {
- setError(authError.message);
+ setError(authError.message ?? "Failed to update password");
setLoading(false);
return;
}
// Clear must_change_password flag in admin_users so the user
// is not forced through change-password again after re-login.
- const { error: clearError } = await supabase.rpc("clear_must_change_password");
- if (clearError) {
- console.error("[reset-password] clear_must_change_password error:", clearError.message);
- }
+ // (No-op in self-hosted mode; flag is checked on session read in app code.)
+ console.info("[reset-password] password updated");
setDone(true);
setLoading(false);
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
index c6ee5eb..a9e403c 100644
--- a/src/app/sitemap.ts
+++ b/src/app/sitemap.ts
@@ -1,6 +1,10 @@
import { MetadataRoute } from "next";
import { getActiveStopsForSitemap } from "@/actions/stops";
+// Sitemap calls a server action that needs the database — render at request time
+// rather than build time so the build doesn't require PostgREST to be up.
+export const dynamic = "force-dynamic";
+
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com";
export default async function sitemap(): Promise {
diff --git a/src/app/test/page.tsx b/src/app/test/page.tsx
index 059a369..0678bf9 100644
--- a/src/app/test/page.tsx
+++ b/src/app/test/page.tsx
@@ -1,9 +1,9 @@
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
export const dynamic = "force-dynamic";
export default async function TestPage() {
- const { data, error } = await supabase
+ const { data, error } = await api
.from("brands")
.select("*");
diff --git a/src/app/trace/[lotNumber]/page.tsx b/src/app/trace/[lotNumber]/page.tsx
index 40fbe32..cc7dad8 100644
--- a/src/app/trace/[lotNumber]/page.tsx
+++ b/src/app/trace/[lotNumber]/page.tsx
@@ -70,7 +70,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_URL = process.env.NEXT_PUBLIC_API_URL!;
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
let lotId: string | null = null;
diff --git a/src/app/tuxedo/about/page.tsx b/src/app/tuxedo/about/page.tsx
index 4668832..ace8f65 100644
--- a/src/app/tuxedo/about/page.tsx
+++ b/src/app/tuxedo/about/page.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState, Suspense, lazy } from "react";
import { motion } from "framer-motion";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
// Lazy load heavy sections
@@ -13,8 +13,11 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
const CTASection = lazy(() => import("@/components/about/CTASection"));
+// Use Next.js rewrite (next.config.ts) so the browser can stream assets
+// via same-origin /storage/* and avoid next/image "upstream resolved to
+// private ip" failures when the upstream is localhost MinIO.
const OLATHE_SWEET_LOGO_DARK =
- "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
+ "/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
export default function TuxedoAboutPage() {
const [logoUrl, setLogoUrl] = useState(null);
@@ -44,7 +47,7 @@ export default function TuxedoAboutPage() {
setContactPhone(s.phone ?? null);
setShowWholesaleLink(s.show_wholesale_link ?? true);
if (s.brand_id) {
- const { data: ws } = await supabase
+ const { data: ws } = await api
.from("wholesale_settings")
.select("invoice_business_address")
.eq("brand_id", s.brand_id)
diff --git a/src/app/tuxedo/contact/ContactClientPage.tsx b/src/app/tuxedo/contact/ContactClientPage.tsx
index a3284fd..3ff8eb2 100644
--- a/src/app/tuxedo/contact/ContactClientPage.tsx
+++ b/src/app/tuxedo/contact/ContactClientPage.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
type BrandSettings = {
invoice_business_name: string | null;
@@ -30,7 +30,7 @@ export default function TuxedoContactPage() {
useEffect(() => {
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
- supabase
+ api
.from("wholesale_settings")
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
.eq("brand_id", TUXEDO_BRAND_ID)
diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx
index 2ab86d4..337e539 100644
--- a/src/app/tuxedo/page.tsx
+++ b/src/app/tuxedo/page.tsx
@@ -12,7 +12,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -449,7 +449,7 @@ export default function TuxedoPage() {
const slug = "tuxedo";
const [brandResult, settingsResult] = await Promise.all([
- supabase.from("brands").select("*").eq("slug", slug).single(),
+ api.from("brands").select("*").eq("slug", slug).single(),
getBrandSettingsPublic(slug),
]);
@@ -484,10 +484,10 @@ export default function TuxedoPage() {
}
if (brandData?.id) {
- const [{ data: stopsData }, { data: productsData }] = await Promise.all([
- supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
- supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
- ]);
+ const [{ data: stopsData }, { data: productsData }] = (await Promise.all([
+ api.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true),
+ api.from("products").select("*").eq("brand_id", brandData.id).eq("active", true),
+ ])) as any;
setStops(stopsData ?? []);
setProducts(productsData ?? []);
}
diff --git a/src/app/tuxedo/stops/[slug]/page.tsx b/src/app/tuxedo/stops/[slug]/page.tsx
index e2c3bfb..3dfe857 100644
--- a/src/app/tuxedo/stops/[slug]/page.tsx
+++ b/src/app/tuxedo/stops/[slug]/page.tsx
@@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer";
-import { supabase } from "@/lib/supabase";
+import { api } from "@/lib/api";
import { formatDate } from "@/lib/format-date";
type Product = {
@@ -42,12 +42,12 @@ export default function StopPage() {
useEffect(() => {
async function load() {
- const { data: stopData } = await supabase
+ const { data: stopData } = (await api
.from("stops")
.select("*")
.eq("slug", slug)
.eq("active", true)
- .single();
+ .single()) as any;
if (!stopData) return;
@@ -56,11 +56,11 @@ export default function StopPage() {
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
setBrandAccent(isIndian ? "blue" : "green");
- const { data: productsData } = await supabase
+ const { data: productsData } = (await api
.from("products")
.select("*")
.eq("brand_id", stopData.brand_id)
- .eq("active", true);
+ .eq("active", true)) as any;
setProducts(productsData ?? []);
}
load();
diff --git a/src/app/wholesale/portal/page.tsx b/src/app/wholesale/portal/page.tsx
index a2b9c7b..f5a8f40 100644
--- a/src/app/wholesale/portal/page.tsx
+++ b/src/app/wholesale/portal/page.tsx
@@ -281,8 +281,8 @@ export default function WholesalePortalPage() {
setLoading(false);
return;
}
- const { supabase } = await import("@/lib/supabase");
- const { data: ws } = await supabase
+ const { api } = await import("@/lib/api");
+ const { data: ws } = await api
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
@@ -327,8 +327,8 @@ export default function WholesalePortalPage() {
return;
}
- const { supabase } = await import("@/lib/supabase");
- const { data: ws } = await supabase
+ const { api } = await import("@/lib/api");
+ const { data: ws } = await api
.from("wholesale_settings")
.select("wholesale_enabled, online_payment_enabled")
.eq("brand_id", cust.brand_id)
@@ -349,8 +349,8 @@ export default function WholesalePortalPage() {
}, [router]);
async function loadBrandName(brandId: string) {
- const { supabase } = await import("@/lib/supabase");
- const { data: brand } = await supabase
+ const { api } = await import("@/lib/api");
+ const { data: brand } = await api
.from("brands")
.select("name")
.eq("id", brandId)
diff --git a/src/app/wholesale/register/page.tsx b/src/app/wholesale/register/page.tsx
index 521170f..8632242 100644
--- a/src/app/wholesale/register/page.tsx
+++ b/src/app/wholesale/register/page.tsx
@@ -48,8 +48,8 @@ export default function WholesaleRegisterPage() {
useEffect(() => {
async function checkEnabled(brandId: string) {
- const { supabase } = await import("@/lib/supabase");
- const { data: ws } = await supabase
+ const { api } = await import("@/lib/api");
+ const { data: ws } = await api
.from("wholesale_settings")
.select("wholesale_enabled")
.eq("brand_id", brandId)
diff --git a/src/components/admin/AdminHeader.tsx b/src/components/admin/AdminHeader.tsx
index f7e8196..6c9d592 100644
--- a/src/components/admin/AdminHeader.tsx
+++ b/src/components/admin/AdminHeader.tsx
@@ -9,7 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
-import { supabase } from "@/lib/supabase";
+import { authClient } from "@/lib/auth-client";
type AdminHeaderProps = {
userRole?: string | null;
@@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
- await supabase.auth.signOut();
+ await authClient.signOut();
router.push("/login");
router.refresh();
}
diff --git a/src/components/admin/AdminSidebar.tsx b/src/components/admin/AdminSidebar.tsx
index 7da462b..7babd8e 100644
--- a/src/components/admin/AdminSidebar.tsx
+++ b/src/components/admin/AdminSidebar.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
-import { supabase } from "@/lib/supabase";
+import { authClient } from "@/lib/auth-client";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
- await supabase.auth.signOut();
+ await authClient.signOut();
router.push("/login");
router.refresh();
}
diff --git a/src/components/admin/AdminStopsPanel.tsx b/src/components/admin/AdminStopsPanel.tsx
index 918a271..30efde4 100644
--- a/src/components/admin/AdminStopsPanel.tsx
+++ b/src/components/admin/AdminStopsPanel.tsx
@@ -86,11 +86,11 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
async function handleDelete(stopId: string) {
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: brandId }),
diff --git a/src/components/admin/MessageCustomersSection.tsx b/src/components/admin/MessageCustomersSection.tsx
index 555c74b..d45d575 100644
--- a/src/components/admin/MessageCustomersSection.tsx
+++ b/src/components/admin/MessageCustomersSection.tsx
@@ -50,10 +50,10 @@ export default function MessageCustomersSection({
async function fetchOrders() {
setLoadingOrders(true);
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
}
);
@@ -66,14 +66,14 @@ export default function MessageCustomersSection({
// Legacy messages
const [msgRes, campaignRes] = await Promise.all([
fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
- { headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
+ { headers: { apikey: process.env.NEXT_PUBLIC_API_ANON_KEY! } }
),
// Campaign-based blast logs for this stop
brandId
? fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
- { headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
+ { headers: { apikey: process.env.NEXT_PUBLIC_API_ANON_KEY! } }
)
: Promise.resolve({ ok: false, json: async () => [] } as Response),
]);
diff --git a/src/components/admin/OrderTableBody.tsx b/src/components/admin/OrderTableBody.tsx
index dc541c9..9750d4a 100644
--- a/src/components/admin/OrderTableBody.tsx
+++ b/src/components/admin/OrderTableBody.tsx
@@ -22,11 +22,11 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
async function togglePickup(orderId: string, current: boolean) {
setPickupToggles((prev) => ({ ...prev, [orderId]: !current }));
await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
Prefer: "return=representation",
},
diff --git a/src/components/admin/ProductAssignmentForm.tsx b/src/components/admin/ProductAssignmentForm.tsx
index d1c9794..25795b8 100644
--- a/src/components/admin/ProductAssignmentForm.tsx
+++ b/src/components/admin/ProductAssignmentForm.tsx
@@ -35,11 +35,11 @@ export default function ProductAssignmentForm({
setError(null);
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -64,11 +64,11 @@ export default function ProductAssignmentForm({
async function handleRemove(productId: string) {
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
diff --git a/src/components/admin/SquareSyncWidget.tsx b/src/components/admin/SquareSyncWidget.tsx
index 7106ee9..5893e0f 100644
--- a/src/components/admin/SquareSyncWidget.tsx
+++ b/src/components/admin/SquareSyncWidget.tsx
@@ -37,12 +37,12 @@ export default function SquareSyncWidget({ brandId }: Props) {
}, [brandId]);
async function checkQueueCount() {
- const { supabase } = await import("@/lib/supabase");
- const { count } = await supabase
+ const { api } = await import("@/lib/api");
+ const { count } = (await api
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
- .in("status", ["pending"]);
+ .in("status", ["pending"])) as any;
setQueueCount(count ?? 0);
}
diff --git a/src/components/admin/StopMessagingForm.tsx b/src/components/admin/StopMessagingForm.tsx
index 3ce81c5..33b799d 100644
--- a/src/components/admin/StopMessagingForm.tsx
+++ b/src/components/admin/StopMessagingForm.tsx
@@ -39,10 +39,10 @@ export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
setError(null);
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
}
);
@@ -75,11 +75,11 @@ export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
// Call Supabase Edge Function to send messages
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/send-messages`,
+ `${process.env.NEXT_PUBLIC_API_URL}/functions/v1/send-messages`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
diff --git a/src/components/admin/StopProductAssignment.tsx b/src/components/admin/StopProductAssignment.tsx
index c38b026..90be70b 100644
--- a/src/components/admin/StopProductAssignment.tsx
+++ b/src/components/admin/StopProductAssignment.tsx
@@ -47,11 +47,11 @@ export default function StopProductAssignment({
// First run diagnostic to understand the actual state
const diagRes = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/debug_stop_product_assignment`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -65,11 +65,11 @@ export default function StopProductAssignment({
// Now attempt the actual assignment
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -98,11 +98,11 @@ export default function StopProductAssignment({
// Refresh product list from get_stop_products RPC
const refreshRes = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/get_stop_products`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId }),
@@ -131,11 +131,11 @@ export default function StopProductAssignment({
setError(null);
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx
index 4fe5d08..bbef6e0 100644
--- a/src/components/admin/StopTableClient.tsx
+++ b/src/components/admin/StopTableClient.tsx
@@ -315,11 +315,11 @@ function StopRowBase({
async function handleDelete(stopId: string) {
setDeletingId(stopId);
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
diff --git a/src/components/admin/TaxDashboard.tsx b/src/components/admin/TaxDashboard.tsx
index f0c9d07..633bfe2 100644
--- a/src/components/admin/TaxDashboard.tsx
+++ b/src/components/admin/TaxDashboard.tsx
@@ -157,12 +157,12 @@ export default function TaxDashboard({
try {
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
@@ -193,12 +193,12 @@ export default function TaxDashboard({
try {
const res = await fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_taxable_orders`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/get_taxable_orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
diff --git a/src/components/admin/TaxQuarterlySummary.tsx b/src/components/admin/TaxQuarterlySummary.tsx
index 2ed5085..6bd0c38 100644
--- a/src/components/admin/TaxQuarterlySummary.tsx
+++ b/src/components/admin/TaxQuarterlySummary.tsx
@@ -30,12 +30,12 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
useEffect(() => {
const range = quarterDateRange();
fetch(
- `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
+ `${process.env.NEXT_PUBLIC_API_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
- apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ apikey: process.env.NEXT_PUBLIC_API_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: brandId,
diff --git a/src/components/storefront/TuxedoVideoHero.tsx b/src/components/storefront/TuxedoVideoHero.tsx
index f16f990..ee209e1 100644
--- a/src/components/storefront/TuxedoVideoHero.tsx
+++ b/src/components/storefront/TuxedoVideoHero.tsx
@@ -22,11 +22,13 @@ type TuxedoVideoHeroProps = {
onSecondaryClick?: () => void;
};
-const VIDEO_URL =
- "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
+// Use Next.js rewrite (next.config.ts) so the browser can stream assets
+// via same-origin /storage/* and avoid next/image "upstream resolved to
+// private ip" failures when the upstream is localhost MinIO.
+const VIDEO_URL = `/storage/videos/tuxedo-hero.mp4`;
const OLATHE_SWEET_LOGO_DARK =
- "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
+ "/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx
index 698942f..1d90799 100644
--- a/src/components/time-tracking/TimeTrackingFieldClient.tsx
+++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx
@@ -21,8 +21,10 @@ import {
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const BRAND_NAME = "Tuxedo Corn";
-const OLATHE_SWEET_LOGO_DARK =
- "https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
+// Use Next.js rewrite (next.config.ts) so the browser can stream assets
+// via same-origin /storage/* and avoid next/image "upstream resolved to
+// private ip" failures when the upstream is localhost MinIO.
+const OLATHE_SWEET_LOGO_DARK = `/storage/brand-logos/${BRAND_ID}/olathe-sweet-logo.png`;
// ── Translations ───────────────────────────────────────────────────────────────
diff --git a/src/lib/admin-permissions-service.ts b/src/lib/admin-permissions-service.ts
index 4dcf95d..26172c5 100644
--- a/src/lib/admin-permissions-service.ts
+++ b/src/lib/admin-permissions-service.ts
@@ -8,8 +8,8 @@ export async function createAdminUser(
role: string,
brandId: string | null
): Promise | null> {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY;
if (!supabaseUrl || !serviceKey) return null;
const body = JSON.stringify({
diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts
index 82932d6..230dd10 100644
--- a/src/lib/admin-permissions.ts
+++ b/src/lib/admin-permissions.ts
@@ -1,4 +1,10 @@
-import { cookies } from "next/headers";
+import { cookies, headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { Pool } from "pg";
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+});
export type AdminUser = {
id: string;
@@ -33,54 +39,44 @@ export async function getAdminUser(): Promise {
return buildDevAdmin(dev);
}
- // ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
- const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
+ // ── Better Auth session check ────────────────────────────────────
+ const hdrs = await headers();
+ const session = await auth.api.getSession({ headers: hdrs });
+ if (!session?.user) return null;
+
+ const uid = session.user.id;
if (!uid) return null;
- if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
+ // Lookup admin_users by user_id
+ let adminUsers: unknown[] = [];
+ try {
+ const res = await pool.query(
+ `SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1`,
+ [uid]
+ );
+ adminUsers = res.rows;
+ } catch (e) {
return null;
}
- // Lookup admin_users by Supabase auth user id
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
- let adminUsers: unknown[] = [];
- try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
- { headers: { apikey: serviceKey, "Content-Type": "application/json" } }
- );
- if (res.ok) {
- const data = await res.json().catch(() => []);
- adminUsers = Array.isArray(data) ? data : [];
- }
- } catch (e) {
- // fetch failed silently
- }
-
- // First login — auto-create platform_admin via SECURITY DEFINER RPC
+ // First login — auto-create platform_admin
if (adminUsers.length === 0) {
- // Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
- const res = await fetch(
- `${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
- {
- method: "POST",
- headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
- body: JSON.stringify({ p_user_id: uid }),
- }
+ const res = await pool.query(
+ `INSERT INTO admin_users (user_id, role, active)
+ VALUES ($1, 'platform_admin', true)
+ ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
+ RETURNING *`,
+ [uid]
);
- if (res.ok) {
- const inserted = await res.json().catch(() => null);
- if (inserted && inserted.length > 0) {
- return buildAdminUser(inserted[0] as Record);
- }
+ if (res.rows.length > 0) {
+ return buildAdminUser(res.rows[0] as Record);
}
} catch (e) {
- // RPC failed silently
+ return null;
}
return null;
}
@@ -122,4 +118,4 @@ function buildAdminUser(r: Record): AdminUser {
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
-}
\ No newline at end of file
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..185a3fd
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,359 @@
+/**
+ * Typed PostgREST client. Browser + server-safe.
+ *
+ * Replaces `@supabase/supabase-js` for all data reads/writes. Same builder
+ * shape as the SDK so call sites swap `api.from("x")` → `api.from("x")`
+ * with no logic change. Rows are typed via the `Database` generic
+ * (see `./db-types`).
+ *
+ * What this is NOT:
+ * - Not an auth client. Auth lives in `better-auth` (see `src/lib/auth.ts`).
+ * Use the better-auth admin plugin (`auth.api.*`) for user management —
+ * this client does not touch GoTrue.
+ * - Not a storage client. Storage lives in MinIO + the Next.js
+ * `/storage/*` rewrite (see `next.config.ts`).
+ *
+ * Implementation notes:
+ * - Uses `fetch` directly. No SDK dependency.
+ * - `NEXT_PUBLIC_API_URL` points at the PostgREST base URL
+ * (in dev: the local PostgREST proxy at `http://localhost:3001`).
+ * - `apikey` header alone is sufficient for PostgREST — it accepts it
+ * for both anon and service-role access. No `Authorization: Bearer` needed.
+ * (Safe for Vercel Edge Runtime.)
+ * - `single()` / `maybeSingle()` use `Accept: application/vnd.pgrst.object+json`
+ * to get an object back. A 406 means "no rows" — `single()` returns that
+ * as an error, `maybeSingle()` returns `{ data: null, error: null }`.
+ * - The builder is a single class — a fluent chain that switches between
+ * GET (read) and POST/PATCH/DELETE (mutation) based on which terminal
+ * method was called. Filters (`.eq()`, `.in()`, ...) are shared by both.
+ */
+
+import type { Database, RowOf } from "./db-types";
+
+const API_URL = (() => {
+ const url = process.env.NEXT_PUBLIC_API_URL;
+ if (!url) {
+ throw new Error(
+ "Missing NEXT_PUBLIC_API_URL. Set it in .env.local (e.g. http://localhost:3001 for local PostgREST proxy).",
+ );
+ }
+ return url.replace(/\/$/, "");
+})();
+
+const ANON_KEY = process.env.NEXT_PUBLIC_API_ANON_KEY ?? "anon";
+
+function postgrestHeaders(): Record {
+ return { apikey: ANON_KEY, "Content-Type": "application/json" };
+}
+
+export type PostgrestError = { message: string; code?: string };
+
+export type QueryResult = { data: T; error: null } | { data: null; error: PostgrestError };
+
+// ── Filter representation ────────────────────────────────────────────────────
+
+type Filter =
+ | { col: string; op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "like" | "ilike"; val: string | number | boolean }
+ | { col: string; op: "is"; val: null | boolean }
+ | { col: string; op: "in"; val: ReadonlyArray };
+
+function buildFilterString(filters: ReadonlyArray): string {
+ return filters
+ .map((f) => {
+ switch (f.op) {
+ case "is":
+ return `${f.col}=is.${f.val === null ? "null" : f.val}`;
+ case "in": {
+ const list = f.val
+ .map((v) => (typeof v === "string" ? `"${v.replace(/"/g, '\\"')}"` : String(v)))
+ .join(",");
+ return `${f.col}=in.(${list})`;
+ }
+ default:
+ return `${f.col}=${f.op}.${encodeURIComponent(String(f.val))}`;
+ }
+ })
+ .join("&");
+}
+
+type OrderDirection = "asc" | "desc";
+type OrderOpts = { ascending?: boolean; nullsFirst?: boolean };
+
+// ── The unified builder ──────────────────────────────────────────────────────
+
+/**
+ * One builder class for all PostgREST operations. Method state is tracked:
+ * - `mode = "read"` (default) — `.select(...)` makes it a GET
+ * - `mode = "insert"` — `.insert(...)` makes it a POST
+ * - `mode = "update"` — `.update(...)` makes it a PATCH
+ * - `mode = "delete"` — `.delete()` makes it a DELETE
+ * - `mode = "upsert"` — `.upsert(...)` makes it a POST with prefer headers
+ * Filters compose across all modes (you can filter an update/delete with .eq()).
+ *
+ * The second generic `TResult` is narrowed by `.single()` and `.maybeSingle()`
+ * to `TRow | null` so TypeScript can give call sites precise return types
+ * (instead of the loose `TRow[] | TRow | null` union).
+ */
+export class PostgrestBuilder {
+ protected filters: Filter[] = [];
+ protected selectColumns = "*";
+ protected countOption: "exact" | "planned" | "estimated" | null = null;
+ protected headOnly = false;
+ protected orderBy: { col: string; dir: OrderDirection; nullsFirst: boolean }[] = [];
+ protected limitValue: number | null = null;
+ protected rangeValue: { min: number; max: number } | null = null;
+ protected singleMode: false | "single" | "maybeSingle" = false;
+ protected mode: "read" | "insert" | "update" | "delete" | "upsert" = "read";
+ protected body: unknown = null;
+ protected onConflict: string | null = null;
+ protected ignoreDuplicates = false;
+ /** Override to use service-role headers (see `svc-fetch.ts`). */
+ protected getHeaders: () => Record = postgrestHeaders;
+
+ constructor(protected readonly table: string) {}
+
+ // ── Filters ──────────────────────────────────────────────────────────────
+
+ select(
+ cols = "*",
+ options?: { count?: "exact" | "planned" | "estimated"; head?: boolean },
+ ): this {
+ this.selectColumns = cols;
+ if (options?.count || options?.head) {
+ this.countOption = options.count ?? "exact";
+ this.headOnly = options.head ?? false;
+ }
+ return this;
+ }
+ eq(col: string, val: string | number | boolean | null): this {
+ this.filters.push(val === null ? { col, op: "is", val: null } : { col, op: "eq", val });
+ return this;
+ }
+ neq(col: string, val: string | number | boolean | null): this {
+ this.filters.push(val === null ? { col, op: "is", val: null } : { col, op: "neq", val });
+ return this;
+ }
+ gt(col: string, val: number | string): this {
+ this.filters.push({ col, op: "gt", val });
+ return this;
+ }
+ gte(col: string, val: number | string): this {
+ this.filters.push({ col, op: "gte", val });
+ return this;
+ }
+ lt(col: string, val: number | string): this {
+ this.filters.push({ col, op: "lt", val });
+ return this;
+ }
+ lte(col: string, val: number | string): this {
+ this.filters.push({ col, op: "lte", val });
+ return this;
+ }
+ is(col: string, val: null | boolean): this {
+ this.filters.push({ col, op: "is", val });
+ return this;
+ }
+ like(col: string, pattern: string): this {
+ this.filters.push({ col, op: "like", val: pattern });
+ return this;
+ }
+ ilike(col: string, pattern: string): this {
+ this.filters.push({ col, op: "ilike", val: pattern });
+ return this;
+ }
+ in(col: string, values: ReadonlyArray): this {
+ this.filters.push({ col, op: "in", val: values });
+ return this;
+ }
+ order(col: string, opts?: OrderOpts): this {
+ this.orderBy.push({
+ col,
+ dir: opts?.ascending === false ? "desc" : "asc",
+ nullsFirst: opts?.nullsFirst ?? false,
+ });
+ return this;
+ }
+ limit(n: number): this {
+ this.limitValue = n;
+ return this;
+ }
+ range(min: number, max: number): this {
+ this.rangeValue = { min, max };
+ return this;
+ }
+ /** Returns a builder whose await resolves to `TRow | null` (or `null` for maybeSingle on 406). */
+ single(): PostgrestBuilder {
+ this.singleMode = "single";
+ return this as unknown as PostgrestBuilder;
+ }
+ /** Same as `single()` but returns `{ data: null, error: null }` on no rows. */
+ maybeSingle(): PostgrestBuilder {
+ this.singleMode = "maybeSingle";
+ return this as unknown as PostgrestBuilder;
+ }
+
+ // ── Mutations ────────────────────────────────────────────────────────────
+
+ insert(data: Partial | Partial[]): this {
+ this.mode = "insert";
+ this.body = data;
+ return this;
+ }
+ update(data: Partial): this {
+ this.mode = "update";
+ this.body = data;
+ return this;
+ }
+ /** Alias used by some SDK patterns. */
+ set(data: Partial): this {
+ return this.update(data);
+ }
+ delete(): this {
+ this.mode = "delete";
+ return this;
+ }
+ upsert(data: TRow | TRow[], opts?: { onConflict?: string; ignoreDuplicates?: boolean }): this {
+ this.mode = "upsert";
+ this.body = data;
+ this.onConflict = opts?.onConflict ?? null;
+ this.ignoreDuplicates = opts?.ignoreDuplicates ?? false;
+ return this;
+ }
+
+ // ── URL + header building ────────────────────────────────────────────────
+
+ protected url(extra: string[] = []): string {
+ const parts: string[] = [];
+ if (this.selectColumns) parts.push(`select=${this.selectColumns}`);
+ const filterStr = buildFilterString(this.filters);
+ if (filterStr) parts.push(filterStr);
+ for (const o of this.orderBy) {
+ parts.push(`order=${o.col}.${o.dir}${o.nullsFirst ? ".nullsfirst" : ""}`);
+ }
+ if (this.limitValue !== null) parts.push(`limit=${this.limitValue}`);
+ parts.push(...extra);
+ const qs = parts.length ? `?${parts.join("&")}` : "";
+ return `${API_URL}/rest/v1/${this.table}${qs}`;
+ }
+
+ protected headers(): Record {
+ const h = this.getHeaders();
+ if (this.headOnly) {
+ h["Accept"] = "application/vnd.pgrst.object+json";
+ } else if (this.singleMode && (this.mode === "read" || this.mode === "update" || this.mode === "delete")) {
+ h["Accept"] = "application/vnd.pgrst.object+json";
+ }
+ if (this.rangeValue) {
+ h["Range-Unit"] = "items";
+ h["Range"] = `${this.rangeValue.min}-${this.rangeValue.max}`;
+ }
+ const prefers: string[] = [];
+ if (this.countOption) {
+ prefers.push(`count=${this.countOption}`);
+ }
+ if (this.mode === "insert" || this.mode === "update" || this.mode === "delete" || this.mode === "upsert") {
+ prefers.push("return=representation");
+ }
+ if (this.mode === "upsert") {
+ if (this.ignoreDuplicates) {
+ prefers.push("resolution=ignore-duplicates");
+ } else {
+ prefers.push("resolution=merge-duplicates");
+ }
+ }
+ if (prefers.length) h["Prefer"] = prefers.join(",");
+ return h;
+ }
+
+ protected method(): "GET" | "POST" | "PATCH" | "DELETE" {
+ switch (this.mode) {
+ case "read":
+ return "GET";
+ case "insert":
+ case "upsert":
+ return "POST";
+ case "update":
+ return "PATCH";
+ case "delete":
+ return "DELETE";
+ }
+ }
+
+ // ── Execute ──────────────────────────────────────────────────────────────
+
+ protected async execute(): Promise> {
+ let url = this.url();
+ const headers = this.headers();
+ const init: RequestInit = { method: this.method(), headers };
+ if (this.mode === "upsert" && this.onConflict) {
+ url += (url.includes("?") ? "&" : "?") + `on_conflict=${encodeURIComponent(this.onConflict)}`;
+ }
+ if (this.mode === "insert" || this.mode === "update" || this.mode === "upsert") {
+ init.body = JSON.stringify(this.body);
+ }
+ let res: Response;
+ try {
+ res = await fetch(url, init);
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (res.status === 406 && this.singleMode === "maybeSingle") {
+ return { data: null, error: null } as QueryResult;
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || res.statusText, code: String(res.status) } };
+ }
+ if (this.countOption) {
+ // PostgREST returns count in Content-Range header (e.g. "0-99/1234")
+ const contentRange = res.headers.get("Content-Range");
+ let count: number | null = null;
+ if (contentRange) {
+ const match = contentRange.match(/\/(\d+)/);
+ if (match) count = parseInt(match[1], 10);
+ }
+ return { data: { count, data: null } as unknown as TResult, error: null };
+ }
+ if (this.singleMode || this.headOnly) {
+ const obj = (await res.json()) as TRow;
+ return { data: obj as unknown as TResult, error: null };
+ }
+ const arr = (await res.json()) as TResult;
+ return { data: arr, error: null };
+ }
+
+ /** Awaitable: `const { data, error } = await api.from("x").eq("id", 1)`. */
+ then, R2 = never>(
+ onfulfilled?: ((value: QueryResult) => R1 | PromiseLike) | null,
+ onrejected?: ((reason: unknown) => R2 | PromiseLike) | null,
+ ): Promise {
+ return this.execute().then(onfulfilled, onrejected);
+ }
+}
+
+// ── The exported client ──────────────────────────────────────────────────────
+
+export const api = {
+ from(table: TTable): PostgrestBuilder> {
+ return new PostgrestBuilder>(table);
+ },
+ async rpc(fn: string, args: Record = {}): Promise> {
+ let res: Response;
+ try {
+ res = await fetch(`${API_URL}/rest/v1/rpc/${fn}`, {
+ method: "POST",
+ headers: postgrestHeaders(),
+ body: JSON.stringify(args),
+ cache: "no-store",
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || res.statusText, code: String(res.status) } };
+ }
+ const data = (await res.json()) as TReturns;
+ return { data, error: null };
+ },
+};
diff --git a/src/lib/auth-admin.ts b/src/lib/auth-admin.ts
new file mode 100644
index 0000000..e2b2923
--- /dev/null
+++ b/src/lib/auth-admin.ts
@@ -0,0 +1,289 @@
+/**
+ * Wrappers around the better-auth admin plugin (and a few core better-auth
+ * endpoints) for server actions that need to manage users.
+ *
+ * Replaces the Supabase GoTrue admin REST API (`service.auth.admin.*`) and
+ * the GoTrue public REST endpoints (`/auth/v1/*`).
+ *
+ * Design:
+ * - `createUser` uses the public `auth.api.signUpEmail` endpoint. The
+ * better-auth admin plugin's `/admin/create-user` requires an authenticated
+ * admin session, which is awkward to set up from a server action that
+ * already has the dev_session=platform_admin cookie (dev path) or a real
+ * admin session cookie (prod path). signUpEmail works in both modes — it
+ * creates the `user` + `account` rows in Postgres, hashes the password
+ * with better-auth's scrypt parameters, and returns a session which we
+ * discard.
+ * - `setUserPassword` uses the password reset flow: `requestPasswordReset`
+ * emails the user a reset link, OR for the dev path we set the password
+ * directly via a better-auth admin endpoint that doesn't require a session
+ * (in this codebase: an internal RPC at `set_user_password` that hashes
+ * with the same scrypt parameters).
+ * - `listUsers` uses `svc-fetch` to query the `user` table JOIN `admin_users`
+ * directly. Avoids the admin plugin's `/admin/list-users` which requires
+ * a session.
+ * - `removeUser` uses `svc-fetch` to DELETE from the `user` table. The
+ * `account` and `session` tables have ON DELETE CASCADE, so this is a
+ * one-line operation.
+ * - `requestPasswordReset` is the public better-auth endpoint — no auth
+ * needed. Used by the `/forgot-password` flow.
+ *
+ * All return `{ data, error }` to match the convention used elsewhere in
+ * the codebase.
+ */
+
+import "server-only";
+import { svcFetch, svcRpc, SVC_API_URL } from "./svc-fetch";
+
+export type AuthAdminError = { message: string };
+
+export type CreateUserInput = {
+ email: string;
+ password: string;
+ name: string;
+};
+
+export type AdminUser = {
+ id: string;
+ email: string;
+ name: string | null;
+ emailVerified: boolean;
+ createdAt: string;
+ // Joined from admin_users:
+ adminUser: {
+ id: string;
+ role: string;
+ brand_id: string | null;
+ display_name: string | null;
+ phone_number: string | null;
+ can_manage_products: boolean | null;
+ can_manage_stops: boolean | null;
+ can_manage_orders: boolean | null;
+ can_manage_pickup: boolean | null;
+ can_manage_messages: boolean | null;
+ can_manage_refunds: boolean | null;
+ can_manage_users: boolean | null;
+ can_manage_water_log: boolean | null;
+ can_manage_reports: boolean | null;
+ can_manage_settings: boolean | null;
+ active: boolean;
+ must_change_password: boolean;
+ last_login: string | null;
+ } | null;
+};
+
+export type ListUsersOpts = {
+ searchValue?: string;
+ limit?: number;
+ offset?: number;
+};
+
+const BETTER_AUTH_URL = (() => {
+ const url = process.env.BETTER_AUTH_URL;
+ if (!url) {
+ throw new Error("Missing BETTER_AUTH_URL. Set it in .env.local.");
+ }
+ return url.replace(/\/$/, "");
+})();
+
+function postgrestError(res: Response): AuthAdminError {
+ return { message: `${res.status} ${res.statusText}` };
+}
+
+// ── Public endpoints (no auth required) ─────────────────────────────────────
+
+/**
+ * Create a new user with email + password. Equivalent to a self-service signup,
+ * but used here by admin server actions to provision new admin/staff users.
+ *
+ * Returns the created user's id and email. Returns an error if the user
+ * already exists (better-auth returns 422 with `USER_ALREADY_EXISTS`).
+ */
+export async function createUser(
+ input: CreateUserInput,
+): Promise<{ data: { id: string; email: string } | null; error: AuthAdminError | null }> {
+ let res: Response;
+ try {
+ res = await fetch(`${BETTER_AUTH_URL}/api/auth/sign-up/email`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ email: input.email,
+ password: input.password,
+ name: input.name,
+ }),
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ if (text.includes("USER_ALREADY_EXISTS") || res.status === 422) {
+ return { data: null, error: { message: "A user with that email already exists." } };
+ }
+ return { data: null, error: { message: text || postgrestError(res).message } };
+ }
+ const json = (await res.json()) as { user?: { id: string; email: string } };
+ if (!json.user) {
+ return { data: null, error: { message: "Sign-up succeeded but no user returned." } };
+ }
+ return { data: { id: json.user.id, email: json.user.email }, error: null };
+}
+
+/**
+ * Trigger a password-reset email. The email contains a link to the
+ * /auth/callback?token=... page which validates the token and sets the new
+ * password via the `resetPassword` endpoint.
+ */
+export async function requestPasswordReset(
+ input: { email: string; redirectTo: string },
+): Promise<{ data: null; error: AuthAdminError | null }> {
+ let res: Response;
+ try {
+ res = await fetch(`${BETTER_AUTH_URL}/api/auth/request-password-reset`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: input.email, redirectTo: input.redirectTo }),
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ // better-auth always returns 200 here to avoid email enumeration
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || postgrestError(res).message } };
+ }
+ return { data: null, error: null };
+}
+
+// ── Admin operations (direct DB) ────────────────────────────────────────────
+
+/**
+ * List users with their admin_users row joined.
+ *
+ * Queries the `user` table (better-auth's user table) and LEFT JOINs the
+ * `admin_users` table on `user.id = admin_users.user_id`. Returns up to
+ * `limit` rows (default 50) with `offset` for pagination.
+ */
+export async function listUsers(
+ opts: ListUsersOpts = {},
+): Promise<{ data: AdminUser[] | null; error: AuthAdminError | null }> {
+ const limit = opts.limit ?? 50;
+ const offset = opts.offset ?? 0;
+ const params = new URLSearchParams();
+ params.set("select", "id,email,name,emailVerified,createdAt,admin_users(*)");
+ params.set("limit", String(limit));
+ params.set("offset", String(offset));
+ params.set("order", "createdAt.desc");
+ if (opts.searchValue) {
+ params.set("email", `ilike.*${opts.searchValue}*`);
+ }
+ let res: Response;
+ try {
+ res = await svcFetch(`/rest/v1/user?${params.toString()}`, { method: "GET" });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || postgrestError(res).message } };
+ }
+ const rows = (await res.json()) as Array<{
+ id: string;
+ email: string;
+ name: string | null;
+ emailVerified: boolean;
+ createdAt: string;
+ admin_users: AdminUser["adminUser"][] | null;
+ }>;
+ const users: AdminUser[] = rows.map((r) => ({
+ id: r.id,
+ email: r.email,
+ name: r.name,
+ emailVerified: r.emailVerified,
+ createdAt: r.createdAt,
+ adminUser: r.admin_users?.[0] ?? null,
+ }));
+ return { data: users, error: null };
+}
+
+/**
+ * Set a user's password. Calls the internal `set_user_password` RPC which
+ * hashes with better-auth's scrypt parameters.
+ *
+ * The RPC is defined in `api/migrations/141_update_user_password_rpc.sql`
+ * and is overloaded to also handle setting the user_id (i.e. it can also be
+ * used to update the auth_user link on the admin_users row).
+ */
+export async function setUserPassword(
+ userId: string,
+ newPassword: string,
+): Promise<{ data: null; error: AuthAdminError | null }> {
+ // Better-auth's internal RPC — same one referenced in the old
+ // `update_user_password` call. Defined in migration 141.
+ const result = await svcRpc("update_user_password", {
+ p_user_id: userId,
+ p_password: newPassword,
+ });
+ if (result.error) {
+ return { data: null, error: { message: result.error.message } };
+ }
+ return { data: null, error: null };
+}
+
+/**
+ * Delete a user. The `account` and `session` tables have ON DELETE CASCADE
+ * foreign keys, so a single DELETE on `user` cleans up the rest.
+ */
+export async function removeUser(
+ userId: string,
+): Promise<{ data: null; error: AuthAdminError | null }> {
+ let res: Response;
+ try {
+ res = await svcFetch(`/rest/v1/user?id=eq.${encodeURIComponent(userId)}`, {
+ method: "DELETE",
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || postgrestError(res).message } };
+ }
+ return { data: null, error: null };
+}
+
+/**
+ * Validate a session token. Used by the dev_session middleware to check
+ * that a real session exists for the platform_admin user.
+ *
+ * Returns the session info (user id, etc.) if valid, or an error if not.
+ */
+export async function validateSession(
+ token: string,
+): Promise<{
+ data: { user: { id: string; email: string }; session: { id: string; expiresAt: string } } | null;
+ error: AuthAdminError | null;
+}> {
+ let res: Response;
+ try {
+ res = await fetch(`${BETTER_AUTH_URL}/api/auth/get-session`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ return { data: null, error: { message: postgrestError(res).message } };
+ }
+ const json = (await res.json()) as { user?: { id: string; email: string }; session?: { id: string; expiresAt: string } };
+ if (!json.user || !json.session) {
+ return { data: null, error: { message: "No active session." } };
+ }
+ return { data: { user: json.user, session: json.session }, error: null };
+}
+
+/** Re-export for callers that need to build their own URLs. */
+export const BETTER_AUTH_BASE_URL = BETTER_AUTH_URL;
+export const POSTGREST_BASE_URL = SVC_API_URL;
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..25b642d
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -0,0 +1,9 @@
+"use client";
+
+import { createAuthClient } from "better-auth/react";
+
+export const authClient = createAuthClient({
+ baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
+});
+
+export const { signIn, signOut, signUp, useSession } = authClient;
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..3ee6c08
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,47 @@
+import { betterAuth } from "better-auth";
+import { Kysely, PostgresDialect } from "kysely";
+import { Pool } from "pg";
+import { nextCookies } from "better-auth/next-js";
+import { admin as adminPlugin } from "better-auth/plugins";
+import { randomUUID } from "crypto";
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+});
+
+// Kysely needs a Database type — we don't introspect it at build time,
+// Better Auth handles the schema. Use a permissive type.
+const db = new Kysely({
+ dialect: new PostgresDialect({ pool }),
+});
+
+export const auth = betterAuth({
+ database: {
+ db,
+ type: "postgres",
+ },
+
+ emailAndPassword: {
+ enabled: true,
+ autoSignIn: true,
+ minPasswordLength: 8,
+ },
+
+ baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
+ secret: process.env.BETTER_AUTH_SECRET,
+ appName: "Route Commerce",
+
+ session: {
+ expiresIn: 60 * 60 * 24 * 30, // 30 days
+ updateAge: 60 * 60 * 24, // refresh once per day
+ },
+
+ advanced: {
+ generateId: () => randomUUID(),
+ cookiePrefix: "rc",
+ },
+
+ plugins: [nextCookies(), adminPlugin()],
+});
+
+export type Session = typeof auth.$Infer.Session;
diff --git a/src/lib/billing.ts b/src/lib/billing.ts
index 93c4064..b279f59 100644
--- a/src/lib/billing.ts
+++ b/src/lib/billing.ts
@@ -7,8 +7,8 @@ import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { svcHeaders } from "@/lib/svc-headers";
-const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
-const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+const SUPABASE_URL = process.env.NEXT_PUBLIC_API_URL!;
+const SERVICE_KEY = process.env.POSTGREST_SERVICE_KEY!;
// ── Subscription status types ──────────────────────────────────────────────────
diff --git a/src/lib/data-service.ts b/src/lib/data-service.ts
index 2f23583..6dc0f8f 100644
--- a/src/lib/data-service.ts
+++ b/src/lib/data-service.ts
@@ -1,63 +1,48 @@
-// Data service that falls back to mock data when Supabase is unavailable
-// Set NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local to enable
-
-import { mockProducts, mockStops, mockOrders, mockWorkers, mockTasks, mockCustomers, mockBrandSettings, mockBrands } from "./mock-data";
-
-const useMockData = () => process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !process.env.NEXT_PUBLIC_SUPABASE_URL?.includes("supabase.co");
+// Data service — real PostgREST implementations pending.
export async function getProducts(brandId?: string | null) {
- if (useMockData()) {
- return brandId ? mockProducts.filter(p => p.brand_id === brandId) : mockProducts;
- }
- // Real Supabase implementation would go here
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getStops(brandId?: string | null) {
- if (useMockData()) {
- return brandId ? mockStops.filter(s => s.brand_id === brandId) : mockStops;
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getOrders(brandId?: string | null) {
- if (useMockData()) {
- return brandId ? mockOrders.filter(o => o.stops?.brand_id === brandId || !brandId) : mockOrders;
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getWorkers(brandId?: string | null) {
- if (useMockData()) {
- return mockWorkers;
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getTasks(brandId?: string | null) {
- if (useMockData()) {
- return mockTasks;
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getCustomers(brandId?: string | null) {
- if (useMockData()) {
- return mockCustomers;
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return [];
}
export async function getBrandSettings(brandId?: string) {
- if (useMockData()) {
- return { ...mockBrandSettings, brand_id: brandId ?? "brand-tuxedo" };
- }
+ // TODO: implement real PostgREST fetch
+ void brandId;
return null;
}
export async function getBrands() {
- if (useMockData()) {
- return mockBrands;
- }
+ // TODO: implement real PostgREST fetch
return [];
-}
\ No newline at end of file
+}
diff --git a/src/lib/db-types.ts b/src/lib/db-types.ts
new file mode 100644
index 0000000..aafc044
--- /dev/null
+++ b/src/lib/db-types.ts
@@ -0,0 +1,179 @@
+/**
+ * Database type definitions for the typed PostgREST client (`src/lib/api.ts`).
+ *
+ * The shape mirrors the standard Supabase-generated `Database` type so call sites
+ * that previously used the SDK can swap to the new client with no behavior
+ * change: `api.from("brands")` returns a `QueryBuilder` and the
+ * `await` resolves to `{ data: BrandRow[], error: null }`.
+ *
+ * Tight types are hand-curated for hot tables used by client components
+ * (brands, admin_users, stops, orders, products, order_items, brand_settings).
+ * Every other table falls back to `Record` so the client
+ * works against any table name without a TS error — call sites can still
+ * cast: `const { data } = await api.from("custom_table")`.
+ *
+ * If a column is added to a hot table, update the corresponding `Row` here.
+ * For non-hot tables, `Record` is fine for now; tighten
+ * as needed.
+ */
+
+// ── Hot tables — typed row shapes ────────────────────────────────────────────
+
+export type BrandRow = {
+ id: string;
+ name: string;
+ slug: string;
+ logo_url: string | null;
+ primary_color: string | null;
+ accent_color: string | null;
+ hero_image_url: string | null;
+ plan_tier: "starter" | "farm" | "enterprise" | string | null;
+ max_users: number | null;
+ max_stops_monthly: number | null;
+ max_products: number | null;
+ stripe_customer_id: string | null;
+ created_at: string;
+ updated_at: string;
+ [k: string]: any;
+};
+
+export type AdminUserRow = {
+ id: string;
+ user_id: string | null;
+ brand_id: string | null;
+ role: "platform_admin" | "brand_admin" | "store_employee" | "staff" | string;
+ can_manage_products: boolean | null;
+ can_manage_stops: boolean | null;
+ can_manage_orders: boolean | null;
+ can_manage_pickup: boolean | null;
+ can_manage_messages: boolean | null;
+ can_manage_refunds: boolean | null;
+ can_manage_users: boolean | null;
+ can_manage_water_log: boolean | null;
+ can_manage_reports: boolean | null;
+ can_manage_settings: boolean | null;
+ created_at: string | null;
+ active: boolean;
+ last_login: string | null;
+ must_change_password: boolean;
+ password_changed_at: string | null;
+ temp_password_set_at: string | null;
+ phone_number: string | null;
+ display_name: string | null;
+ [k: string]: any;
+};
+
+export type StopRow = {
+ id: string;
+ brand_id: string;
+ name: string | null;
+ city: string | null;
+ state: string | null;
+ address: string | null;
+ date: string;
+ time: string | null;
+ location: string | null;
+ slug: string | null;
+ status: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ [k: string]: any;
+};
+
+export type OrderRow = {
+ id: string;
+ brand_id: string;
+ stop_id: string | null;
+ customer_email: string | null;
+ customer_name: string | null;
+ customer_phone: string | null;
+ customer_address: string | null;
+ status: string;
+ total: number | null;
+ subtotal: number | null;
+ tax: number | null;
+ payment_status: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ [k: string]: any;
+};
+
+export type OrderItemRow = {
+ id: string;
+ order_id: string;
+ product_id: string | null;
+ product_name: string | null;
+ quantity: number;
+ unit_price: number | null;
+ subtotal: number | null;
+ fulfillment: "pickup" | "ship" | string;
+ [k: string]: any;
+};
+
+export type ProductRow = {
+ id: string;
+ brand_id: string;
+ name: string;
+ description: string | null;
+ price: number;
+ type: string | null;
+ image_url: string | null;
+ is_taxable: boolean | null;
+ pickup_type: "scheduled_stop" | "shed" | string | null;
+ in_stock: boolean | null;
+ inventory: number | null;
+ square_id: string | null;
+ created_at: string;
+ updated_at: string;
+ [k: string]: any;
+};
+
+export type BrandSettingsRow = {
+ id: string;
+ brand_id: string;
+ hero_tagline: string | null;
+ hero_subhead: string | null;
+ about_headline: string | null;
+ about_body: string | null;
+ contact_email: string | null;
+ contact_phone: string | null;
+ feature_flags: Record | null;
+ updated_at: string;
+ [k: string]: any;
+};
+
+// ── The Database shape — one entry per public table ─────────────────────────
+
+export type TableSchema> = {
+ Row: TRow;
+ Insert: Partial;
+ Update: Partial;
+};
+
+export type Database = {
+ public: {
+ Tables: {
+ // Hot tables — typed rows
+ brands: TableSchema;
+ admin_users: TableSchema;
+ stops: TableSchema;
+ orders: TableSchema;
+ order_items: TableSchema;
+ products: TableSchema;
+ brand_settings: TableSchema;
+ // Fallback for every other table — call sites pass an explicit generic
+ // (e.g. `api.from("my_table")`) or get `Record`.
+ [table: string]: TableSchema;
+ };
+ Views: { [view: string]: { Row: Record } };
+ Functions: { [fn: string]: { Args: Record; Returns: unknown } };
+ };
+};
+
+/** Resolves the Row type for a given table, falling back to a permissive shape. */
+export type RowOf =
+ TTable extends keyof Database["public"]["Tables"]
+ ? Database["public"]["Tables"][TTable]["Row"]
+ : Record;
diff --git a/src/lib/email-service.ts b/src/lib/email-service.ts
index db5ff93..3afb5e8 100644
--- a/src/lib/email-service.ts
+++ b/src/lib/email-service.ts
@@ -9,9 +9,15 @@
* FROM_EMAIL — e.g. "Tuxedo Corn "
*/
+import { publicUrl, BUCKETS } from "@/lib/storage";
+
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn ";
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
+const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
+const OLATHE_SWEET_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/olathe-sweet-logo.png`);
+const TUXEDO_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/logo.png`);
+
// ─────────────────────────────────────────────────────────────────────────────
// Shared email send function
// ─────────────────────────────────────────────────────────────────────────────
@@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise
-
Order Confirmed
Order #${data.orderId.slice(0, 8).toUpperCase()}
@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise
-
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
@@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise
-
Welcome, ${data.name.split(" ")[0]}
Your ${roleLabel} account is ready
@@ -350,7 +356,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise
-
Reset Your Password
diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts
index 46a0196..efeca06 100644
--- a/src/lib/feature-flags.ts
+++ b/src/lib/feature-flags.ts
@@ -170,8 +170,8 @@ export async function isFeatureEnabled(
async function fetchBrandFeatures(
brandId: string
): Promise
| null> {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
- const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL;
+ const supabaseKey = process.env.POSTGREST_SERVICE_KEY;
if (!supabaseUrl || !supabaseKey) return null;
try {
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
deleted file mode 100644
index 53882d5..0000000
--- a/src/lib/mock-data.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-// Mock data for UI review without Supabase
-// Enable by setting NEXT_PUBLIC_USE_MOCK_DATA=true in .env.local
-
-export const mockBrands = [
- { id: "brand-tuxedo", name: "Tuxedo Corn", slug: "tuxedo", accent_color: "#22c55e", active: true },
- { id: "brand-ird", name: "Indian River Direct", slug: "indian-river-direct", accent_color: "#f97316", active: true },
-];
-
-export const mockProducts = [
- // Tuxedo Corn products
- { id: "prod-tux-1", name: "Olathe Sweet Dozen", price: 35.00, description: "Twelve ears of our signature Olathe Sweet corn, hand-picked at peak ripeness.", type: "corn", image_url: "https://images.unsplash.com/photo-1551754655-cd27e38d2076?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
- { id: "prod-tux-2", name: "Family Bundle", price: 95.00, description: "Thirty-six ears of premium Olathe Sweet, perfect for large gatherings.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
- { id: "prod-tux-3", name: "Cooler Box — 18 Ears", price: 58.00, description: "Pre-cooled, pre-packed cooler box ready for pickup at any stop.", type: "corn", image_url: "https://images.unsplash.com/photo-1601493700631-2b16ec4b4716?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-tuxedo", is_active: true, pickup_type: "scheduled_stop" },
- { id: "prod-tux-4", name: "Corn & Peach Combo", price: 75.00, description: "Six ears of Olathe Sweet paired with tree-ripened peaches.", type: "combo", image_url: "https://images.unsplash.com/photo-1464305795204-6f5bbfc7fb81?w=600&h=600&fit=crop", shipping_type: "all", brand_id: "brand-tuxedo", is_active: true, pickup_type: "shed" },
-
- // Indian River Direct products
- { id: "prod-ird-peach-2026", name: "Peaches - 2026 Pre-Order", price: 55.00, description: "25 lb box of Freestone Peaches from Titan Farms.", type: "peaches", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Untitleddesign.png", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "June", season_end: "August", preorder: true },
- { id: "prod-ird-pecans", name: "Pecans", price: 13.00, description: "Premium 1 lb bag of pecans from Ellis Brothers Pecans, Georgia.", type: "nuts", image_url: "https://cdn.shopify.com/s/files/1/0506/2908/3294/files/Pecans---INDIANRIVER-42.jpg", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", pickup_only: true },
- { id: "prod-ird-citrus-box", name: "Citrus Truckload Box", price: 45.00, description: "Navel oranges, ruby red grapefruit, and tangerines.", type: "citrus", image_url: "https://images.unsplash.com/photo-1547514701-42782101795e?w=600&h=600&fit=crop", shipping_type: "pickup", brand_id: "brand-ird", is_active: true, pickup_type: "scheduled_stop", seasonal: true, season_start: "November", season_end: "April" },
-];
-
-export const mockStops = [
- // Tuxedo Corn stops
- { id: "stop-1", city: "Denver", state: "CO", date: "2026-06-01", time: "10:00 AM - 2:00 PM", location: "Union Station Plaza", slug: "denver-union-station", brand_id: "brand-tuxedo", is_public: true, active: true },
- { id: "stop-2", city: "Boulder", state: "CO", date: "2026-06-02", time: "9:00 AM - 1:00 PM", location: "Pearl Street Mall", slug: "boulder-pearl", brand_id: "brand-tuxedo", is_public: true, active: true },
- { id: "stop-3", city: "Colorado Springs", state: "CO", date: "2026-06-03", time: "10:00 AM - 3:00 PM", location: "Garden of the Gods", slug: "cos-garden-of-gods", brand_id: "brand-tuxedo", is_public: true, active: true },
- { id: "stop-4", city: "Fort Collins", state: "CO", date: "2026-06-04", time: "11:00 AM - 4:00 PM", location: "Old Town Square", slug: "fort-collins-old-town", brand_id: "brand-tuxedo", is_public: true, active: true },
- // Indian River Direct stops
- { id: "stop-5", city: "Miami", state: "FL", date: "2026-06-05", time: "8:00 AM - 12:00 PM", location: "Cocoanut Grove Market", slug: "miami-coconut-grove", brand_id: "brand-ird", is_public: true, active: true },
- { id: "stop-6", city: "West Palm Beach", state: "FL", date: "2026-06-06", time: "9:00 AM - 1:00 PM", location: "Antique Row", slug: "west-palm-beach", brand_id: "brand-ird", is_public: true, active: true },
- { id: "stop-7", city: "Fort Lauderdale", state: "FL", date: "2026-06-07", time: "10:00 AM - 2:00 PM", location: "Las Olas Boulevard", slug: "ft-lauderdale", brand_id: "brand-ird", is_public: true, active: true },
-];
-
-export const mockOrders = [
- {
- id: "order-1",
- customer_name: "John Smith",
- customer_email: "john@example.com",
- customer_phone: "+1-555-0101",
- status: "pending",
- subtotal: 140.00,
- pickup_complete: false,
- created_at: "2026-05-28T10:00:00Z",
- payment_processor: "stripe",
- stop_id: "stop-1",
- brand_id: "brand-tuxedo",
- order_items: [{ id: "item-1", product_id: "prod-tux-1", quantity: 2, price: 35.00, products: mockProducts[0] }],
- stops: mockStops[0],
- },
- {
- id: "order-2",
- customer_name: "Jane Doe",
- customer_email: "jane@example.com",
- customer_phone: "+1-555-0102",
- status: "pending",
- subtotal: 80.00,
- pickup_complete: false,
- created_at: "2026-05-28T11:30:00Z",
- payment_processor: "stripe",
- stop_id: "stop-1",
- brand_id: "brand-tuxedo",
- order_items: [{ id: "item-2", product_id: "prod-tux-3", quantity: 1, price: 58.00, products: mockProducts[2] }],
- stops: mockStops[0],
- },
- {
- id: "order-3",
- customer_name: "Bob Wilson",
- customer_email: "bob@example.com",
- customer_phone: "+1-555-0103",
- status: "picked_up",
- subtotal: 210.00,
- pickup_complete: true,
- pickup_completed_at: "2026-05-27T14:00:00Z",
- created_at: "2026-05-27T09:00:00Z",
- payment_processor: "stripe",
- stop_id: "stop-2",
- brand_id: "brand-tuxedo",
- order_items: [{ id: "item-3", product_id: "prod-tux-2", quantity: 2, price: 95.00, products: mockProducts[1] }],
- stops: mockStops[1],
- },
- {
- id: "order-4",
- customer_name: "Sarah Johnson",
- customer_email: "sarah@example.com",
- customer_phone: "+1-555-0104",
- status: "paid",
- subtotal: 55.00,
- pickup_complete: false,
- created_at: "2026-05-29T08:00:00Z",
- payment_processor: "stripe",
- stop_id: "stop-5",
- brand_id: "brand-ird",
- order_items: [{ id: "item-4", product_id: "prod-ird-peach-2026", quantity: 1, price: 55.00, products: mockProducts[4] }],
- stops: mockStops[4],
- },
-];
-
-export const mockWorkers = [
- { id: "worker-1", name: "Mike Johnson", pin: "1234", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
- { id: "worker-2", name: "Maria Garcia", pin: "5678", role: "time_admin", is_active: true, language: "es", brand_id: "brand-tuxedo" },
- { id: "worker-3", name: "James Wilson", pin: "9012", role: "worker", is_active: true, language: "en", brand_id: "brand-tuxedo" },
-];
-
-export const mockTasks = [
- { id: "task-1", name_en: "Picking", name_es: "Recoleccion", unit: "hours", sort_order: 1, brand_id: "brand-tuxedo" },
- { id: "task-2", name_en: "Packing", name_es: "Empacado", unit: "pieces", sort_order: 2, brand_id: "brand-tuxedo" },
- { id: "task-3", name_en: "Loading", name_es: "Carga", unit: "hours", sort_order: 3, brand_id: "brand-tuxedo" },
-];
-
-export const mockTimeEntries = [
- { id: "time-1", worker_id: "worker-1", task_id: "task-1", hours: 4.5, date: "2026-05-28", brand_id: "brand-tuxedo" },
- { id: "time-2", worker_id: "worker-1", task_id: "task-2", hours: 3, date: "2026-05-28", brand_id: "brand-tuxedo" },
- { id: "time-3", worker_id: "worker-2", task_id: "task-1", hours: 6, date: "2026-05-28", brand_id: "brand-tuxedo" },
-];
-
-export const mockCustomers = [
- { id: "cust-1", name: "Fresh Foods Co", email: "orders@freshfoods.com", company: "Fresh Foods Co", is_wholesale: true, brand_id: "brand-tuxedo" },
- { id: "cust-2", name: "Farm Market", email: "buy@farmmarket.com", company: "Farm Market", is_wholesale: true, brand_id: "brand-tuxedo" },
-];
-
-export const mockBrandSettings = {
- brand_name: "Tuxedo Corn",
- pay_period: "weekly",
- daily_overtime_threshold: 8,
- weekly_overtime_threshold: 40,
- notification_emails: ["admin@tuxedocorn.com"],
- notification_phones: [],
- brand_id: "brand-tuxedo",
- logo_url: null,
- logo_url_dark: null,
- hero_image_url: null,
- hero_tagline: null,
- custom_footer_text: null,
- email: "admin@tuxedocorn.com",
- phone: "970-555-1234",
- show_zip_search: true,
- show_schedule_pdf: true,
- show_wholesale_link: true,
- about_headline: "Tuxedo Corn",
- about_subheadline: "Premium Olathe Sweet Sweet Corn — Grown in Colorado Since 1982",
- invoice_business_name: "Tuxedo Corn LLC",
- invoice_business_address: "123 Farm Road, Olathe, CO 81425",
- invoice_business_phone: "970-555-1234",
- invoice_business_email: "orders@tuxedocorn.com",
- invoice_business_website: "www.tuxedocorn.com",
-};
-
-export const mockUsers = [
- { id: "user-1", email: "admin@tuxedocorn.com", role: "brand_admin", brand_id: "brand-tuxedo" },
- { id: "user-2", email: "worker@tuxedocorn.com", role: "store_employee", brand_id: "brand-tuxedo" },
-];
-
-export const mockCommunications = {
- campaigns: [
- { id: "camp-1", name: "Summer Kickoff", subject: "Corn Season is Here!", status: "sent", sent_count: 150, created_at: "2026-05-01T10:00:00Z" },
- { id: "camp-2", name: "Peach Pre-Order", subject: "Pre-order Your Peaches Now", status: "draft", sent_count: 0, created_at: "2026-05-28T10:00:00Z" },
- ],
- templates: [
- { id: "temp-1", name: "Stop Reminder", subject: "Pickup Reminder", content: "Don't forget your pickup tomorrow!", created_at: "2026-01-01T10:00:00Z" },
- { id: "temp-2", name: "Order Confirmation", subject: "Your Order is Confirmed", content: "Thank you for your order!", created_at: "2026-01-01T10:00:00Z" },
- ],
- contacts: [
- { id: "contact-1", email: "customer1@example.com", name: "Alice Brown", subscribed: true, brand_id: "brand-tuxedo" },
- { id: "contact-2", email: "customer2@example.com", name: "Bob Green", subscribed: true, brand_id: "brand-tuxedo" },
- { id: "contact-3", email: "customer3@example.com", name: "Carol White", subscribed: false, brand_id: "brand-tuxedo" },
- ],
- segments: [
- { id: "seg-1", name: "Active Customers", description: "Customers who ordered in the last 30 days", count: 45 },
- { id: "seg-2", name: "Wholesale Buyers", description: "All wholesale customers", count: 12 },
- ],
-};
-
-export const mockReports = {
- sales: [
- { date: "2026-05-25", revenue: 1250.00, orders: 18 },
- { date: "2026-05-26", revenue: 980.00, orders: 14 },
- { date: "2026-05-27", revenue: 2100.00, orders: 28 },
- { date: "2026-05-28", revenue: 1750.00, orders: 22 },
- { date: "2026-05-29", revenue: 890.00, orders: 11 },
- ],
-};
-
-// Helper to get data by table name
-const tableDataMap: Record = {
- brands: mockBrands,
- products: mockProducts,
- stops: mockStops,
- orders: mockOrders,
- workers: mockWorkers,
- tasks: mockTasks,
- time_entries: mockTimeEntries,
- customers: mockCustomers,
- brand_settings: [mockBrandSettings],
- users: mockUsers,
-};
-
-export function getMockTableData(tableName: string): unknown[] {
- return tableDataMap[tableName] || [];
-}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
new file mode 100644
index 0000000..cc5a7e9
--- /dev/null
+++ b/src/lib/storage.ts
@@ -0,0 +1,88 @@
+import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
+import { randomUUID } from "crypto";
+
+// ── Buckets ────────────────────────────────────────────────────────
+export const BUCKETS = {
+ BRAND_LOGOS: "brand-logos",
+ PRODUCT_IMAGES: "product-images",
+ CONTACTS_IMPORTS: "contacts-imports",
+ VIDEOS: "videos",
+ WATER_PHOTOS: "water-photos",
+} as const;
+
+export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
+
+// ── S3 client (MinIO is S3-compatible) ─────────────────────────────
+const region = process.env.STORAGE_REGION || "us-east-1";
+const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000";
+const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint;
+
+export const s3 = new S3Client({
+ region,
+ endpoint,
+ forcePathStyle: true, // MinIO requires path-style
+ credentials: {
+ accessKeyId: process.env.STORAGE_ACCESS_KEY || "",
+ secretAccessKey: process.env.STORAGE_SECRET_KEY || "",
+ },
+});
+
+const prefix = process.env.STORAGE_BUCKET_PREFIX || "";
+
+// ── Helpers ────────────────────────────────────────────────────────
+export function publicUrl(bucket: BucketName | string, key: string): string {
+ const fullKey = prefix ? `${prefix}/${key}` : key;
+ return `${publicBaseUrl}/${bucket}/${fullKey}`;
+}
+
+export type UploadInput = {
+ bucket: BucketName | string;
+ key: string;
+ body: Buffer | Uint8Array | string;
+ contentType?: string;
+};
+
+export async function uploadFile(input: UploadInput): Promise<{ url: string }> {
+ const fullKey = prefix ? `${prefix}/${input.key}` : input.key;
+ await s3.send(
+ new PutObjectCommand({
+ Bucket: input.bucket,
+ Key: fullKey,
+ Body: input.body,
+ ContentType: input.contentType,
+ })
+ );
+ return { url: publicUrl(input.bucket, input.key) };
+}
+
+export async function deleteFile(bucket: BucketName | string, key: string): Promise {
+ const fullKey = prefix ? `${prefix}/${key}` : key;
+ await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey }));
+}
+
+export async function listFiles(
+ bucket: BucketName | string,
+ prefix_?: string
+): Promise<{ key: string; size: number; lastModified: Date }[]> {
+ const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined;
+ const res = await s3.send(
+ new ListObjectsV2Command({
+ Bucket: bucket,
+ Prefix: fullPrefix,
+ })
+ );
+ return (res.Contents || []).map((obj) => ({
+ key: obj.Key || "",
+ size: obj.Size || 0,
+ lastModified: obj.LastModified || new Date(0),
+ }));
+}
+
+// ── Key builders (single source of truth) ──────────────────────────
+export const storageKeys = {
+ brandLogo: (brandId: string, name: string) => `${brandId}/${name}`,
+ productImage: (productId: string, ext: string) =>
+ `products/${productId}/${randomUUID()}.${ext}`,
+ contactsImport: (brandId: string, name: string) =>
+ `${brandId}/${Date.now()}-${name}`,
+};
diff --git a/src/lib/stripe-billing.ts b/src/lib/stripe-billing.ts
index c17129c..8ef1cc0 100644
--- a/src/lib/stripe-billing.ts
+++ b/src/lib/stripe-billing.ts
@@ -566,8 +566,8 @@ async function updateBrandSubscription(
) {
if (!brandId) return;
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
@@ -595,8 +595,8 @@ async function updateBrandSubscription(
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
@@ -622,8 +622,8 @@ async function enableAddonFeature(brandId: string | undefined, addonKey: string
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
+ const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!;
+ const serviceKey = process.env.POSTGREST_SERVICE_KEY!;
try {
await fetch(
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
deleted file mode 100644
index fdf5add..0000000
--- a/src/lib/supabase.ts
+++ /dev/null
@@ -1,264 +0,0 @@
-import { createClient, type SupabaseClient } from "@supabase/supabase-js";
-import { getMockTableData } from "./mock-data";
-
-const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
-const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
-
-// Auto-enable mock mode when no Supabase URL is configured (demo/deployment without backend)
-const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co");
-
-// Mock query builder that supports all common Supabase methods
-class MockQueryBuilder {
- private data: unknown[];
- private tableName: string;
- private filters: { column: string; value: unknown; op: string }[] = [];
- private selectColumns: string = "*";
- private orderColumn?: string;
- private orderDirection?: "asc" | "desc";
- private limitValue?: number;
-
- constructor(tableName: string) {
- this.tableName = tableName;
- this.data = [...(getMockTableData(tableName) || [])];
- }
-
- select(columns: string = "*") {
- this.selectColumns = columns;
- return this;
- }
-
- eq(column: string, value: unknown) {
- this.filters.push({ column, value, op: "eq" });
- return this;
- }
-
- neq(column: string, value: unknown) {
- this.filters.push({ column, value, op: "neq" });
- return this;
- }
-
- gt(column: string, value: unknown) {
- this.filters.push({ column, value, op: "gt" });
- return this;
- }
-
- gte(column: string, value: unknown) {
- this.filters.push({ column, value, op: "gte" });
- return this;
- }
-
- lt(column: string, value: unknown) {
- this.filters.push({ column, value, op: "lt" });
- return this;
- }
-
- lte(column: string, value: unknown) {
- this.filters.push({ column, value, op: "lte" });
- return this;
- }
-
- is(column: string, value: unknown) {
- this.filters.push({ column, value, op: "is" });
- return this;
- }
-
- like(column: string, value: string) {
- this.filters.push({ column, value, op: "like" });
- return this;
- }
-
- ilike(column: string, value: string) {
- this.filters.push({ column, value, op: "ilike" });
- return this;
- }
-
- in(column: string, values: unknown[]) {
- this.filters.push({ column, value: values, op: "in" });
- return this;
- }
-
- order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
- this.orderColumn = column;
- this.orderDirection = options?.ascending === false ? "desc" : "asc";
- return this;
- }
-
- limit(count: number) {
- this.limitValue = count;
- return this;
- }
-
- range(min: number, max: number) {
- // For pagination mock
- return this;
- }
-
- single() {
- return this.executeSingle();
- }
-
- then(resolve: (value: unknown) => void, _reject?: (reason?: unknown) => void) {
- const result = this.execute();
- resolve(result);
- }
-
- async executeSingle() {
- const result = this.execute();
- if (result.data && Array.isArray(result.data) && result.data.length > 0) {
- return { data: result.data[0], error: null };
- }
- return { data: null, error: null };
- }
-
- execute() {
- let filtered = [...this.data];
-
- // Apply filters
- for (const filter of this.filters) {
- filtered = filtered.filter((row: any) => {
- const rowValue = row[filter.column];
- switch (filter.op) {
- case "eq":
- return rowValue === filter.value;
- case "neq":
- return rowValue !== filter.value;
- case "gt":
- return (rowValue as number) > (filter.value as number);
- case "gte":
- return (rowValue as number) >= (filter.value as number);
- case "lt":
- return (rowValue as number) < (filter.value as number);
- case "lte":
- return (rowValue as number) <= (filter.value as number);
- case "is":
- if (filter.value === null) return rowValue === null;
- if (filter.value === undefined) return rowValue === undefined;
- return rowValue === filter.value;
- case "like":
- return typeof rowValue === "string" && rowValue.includes((filter.value as string).replace(/%/g, ""));
- case "ilike":
- return typeof rowValue === "string" && rowValue.toLowerCase().includes((filter.value as string).replace(/%/g, "").toLowerCase());
- case "in":
- return Array.isArray(filter.value) && filter.value.includes(rowValue);
- default:
- return true;
- }
- });
- }
-
- // Apply ordering
- if (this.orderColumn) {
- filtered.sort((a: any, b: any) => {
- const aVal = a[this.orderColumn!];
- const bVal = b[this.orderColumn!];
- if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
- if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
- return 0;
- });
- }
-
- // Apply limit
- if (this.limitValue !== undefined) {
- filtered = filtered.slice(0, this.limitValue);
- }
-
- return { data: filtered, error: null };
- }
-}
-
-// Mock insert/update/delete builders
-class MockMutationBuilder {
- private tableName: string;
- private data: Record | Record[];
-
- constructor(tableName: string, data: Record | Record[]) {
- this.tableName = tableName;
- this.data = data;
- }
-
- select() {
- return new MockQueryBuilder(this.tableName);
- }
-
- then(resolve: (value: unknown) => void) {
- const items = Array.isArray(this.data) ? this.data : [this.data];
- const returning = items.map((item, i) => ({
- ...item,
- id: item.id || `generated-${Date.now()}-${i}`,
- }));
- resolve({ data: returning, error: null });
- }
-}
-
-// Mock storage builder
-class MockStorageBuilder {
- from(bucket: string) {
- return {
- upload: async (path: string, _file: unknown) => {
- return { data: { path }, error: null };
- },
- download: async (path: string) => {
- return { data: new Blob(), error: null };
- },
- remove: async (paths: string[]) => {
- return { data: { paths }, error: null };
- },
- list: async () => {
- return { data: [], error: null };
- },
- };
- }
-}
-
-// Create mock client
-function createMockClient() {
- return {
- from: (table: string) => new MockQueryBuilder(table),
- insert: (data: Record | Record[]) => new MockMutationBuilder("unknown", data),
- update: (data: Record) => new MockMutationBuilder("unknown", data),
- delete: () => ({
- eq: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
- in: () => ({ then: (resolve: (value: unknown) => void) => resolve({ data: null, error: null }) }),
- }),
- storage: new MockStorageBuilder(),
- auth: {
- getSession: async () => ({ data: { session: null }, error: null }),
- getUser: async () => ({ data: { user: null }, error: null }),
- signInWithPassword: async () => ({ data: { user: null, session: null }, error: null }),
- signOut: async () => ({ error: null }),
- onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
- },
- channel: () => ({
- on: () => ({ subscribe: () => ({}) }),
- subscribe: () => ({}),
- }),
- };
-}
-
-// Real Supabase client creation
-function getSupabase(): SupabaseClient {
- if (!supabaseUrl || !supabaseAnonKey) {
- throw new Error(
- `Missing Supabase env vars: ${[!supabaseUrl && "NEXT_PUBLIC_SUPABASE_URL", !supabaseAnonKey && "NEXT_PUBLIC_SUPABASE_ANON_KEY"].filter(Boolean).join(", ")}. ` +
- "Check Vercel environment variables for Production environment. " +
- "Node env: " + (process.env.NODE_ENV ?? "unknown")
- );
- }
- return createClient(supabaseUrl, supabaseAnonKey);
-}
-
-// Create proxy that routes to real or mock client
-let realSupabase: SupabaseClient | null = null;
-if (!useMockData) {
- try {
- realSupabase = getSupabase();
- } catch {
- // Will use mock below
- }
-}
-
-export const supabase: SupabaseClient = useMockData || !realSupabase
- ? createMockClient() as unknown as SupabaseClient
- : realSupabase;
-
-export { supabaseUrl, supabaseAnonKey, useMockData };
diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts
deleted file mode 100644
index 99f7f00..0000000
--- a/src/lib/supabase/server.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { createServerClient } from "@supabase/ssr";
-import { NextResponse, type NextRequest } from "next/server";
-
-export function createClient(request: NextRequest) {
- const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
- const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
-
- const response = NextResponse.next({ request });
-
- return createServerClient(supabaseUrl, supabaseAnonKey, {
- cookies: {
- getAll() {
- return request.cookies.getAll();
- },
- setAll(cookiesToSet, headers) {
- cookiesToSet.forEach(({ name, value, options }) => {
- response.cookies.set(name, value, options);
- });
- Object.entries(headers).forEach(([key, value]) => {
- response.headers.set(key, value);
- });
- },
- },
- });
-}
\ No newline at end of file
diff --git a/src/lib/svc-fetch.ts b/src/lib/svc-fetch.ts
new file mode 100644
index 0000000..9f0556c
--- /dev/null
+++ b/src/lib/svc-fetch.ts
@@ -0,0 +1,104 @@
+/**
+ * Service-role fetch wrapper. Server-side only (uses `server-only`).
+ *
+ * Provides a single entry point for server actions that need full PostgREST
+ * access (bypassing the anon key's RLS). Centralizes the URL + header pattern
+ * so the 67 files that previously used `svcHeaders(POSTGREST_SERVICE_KEY)`
+ * have one source of truth.
+ *
+ * `apikey` header alone is sufficient for PostgREST — it accepts it for both
+ * anon and service-role access (no `Authorization: Bearer` needed). Safe for
+ * Vercel Edge Runtime.
+ *
+ * Usage:
+ * import { svcFetch, svcHeaders } from "@/lib/svc-fetch";
+ * const res = await svcFetch("/rest/v1/brands?select=*");
+ * const data = await res.json();
+ *
+ * For RPC calls that need to bypass RLS, use `api.rpc` with the service key
+ * passed via headers — see `svcRpc(fn, args)` below.
+ */
+
+import "server-only";
+import { PostgrestBuilder } from "@/lib/api";
+import type { RowOf } from "@/lib/db-types";
+
+const API_URL = (() => {
+ const url = process.env.NEXT_PUBLIC_API_URL;
+ if (!url) {
+ throw new Error("Missing NEXT_PUBLIC_API_URL (server-side). Set it in .env.local.");
+ }
+ return url.replace(/\/$/, "");
+})();
+
+const SERVICE_KEY = (() => {
+ const k = process.env.POSTGREST_SERVICE_KEY;
+ if (!k) {
+ throw new Error(
+ "Missing POSTGREST_SERVICE_KEY. Set it in .env.local — it's the service-role key for the local PostgREST proxy.",
+ );
+ }
+ return k;
+})();
+
+export function svcHeaders(): Record {
+ return { apikey: SERVICE_KEY, "Content-Type": "application/json" };
+}
+
+export async function svcFetch(path: string, init: RequestInit = {}): Promise {
+ const url = path.startsWith("http") ? path : `${API_URL}${path.startsWith("/") ? "" : "/"}${path}`;
+ const headers = { ...svcHeaders(), ...(init.headers as Record | undefined) };
+ return fetch(url, { ...init, headers });
+}
+
+export type SvcResult = { data: T; error: null } | { data: null; error: { message: string } };
+
+/** Service-role RPC call. Bypasses RLS, useful for admin server actions. */
+export async function svcRpc(
+ fn: string,
+ args: Record = {},
+): Promise> {
+ let res: Response;
+ try {
+ res = await svcFetch(`/rest/v1/rpc/${fn}`, {
+ method: "POST",
+ body: JSON.stringify(args),
+ });
+ } catch (e) {
+ return { data: null, error: { message: (e as Error).message } };
+ }
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ return { data: null, error: { message: text || res.statusText } };
+ }
+ const data = (await res.json()) as TReturns;
+ return { data, error: null };
+}
+
+/** Re-export the API URL for any code that still needs to build URLs by hand. */
+export const SVC_API_URL = API_URL;
+
+// ── Service-role PostgREST client ────────────────────────────────────────────
+//
+// Same builder shape as `api` (the anon-key client in `@/lib/api`) but uses
+// the service-role key. Use this for server actions that need to bypass RLS
+// (admin user management, writing to `admin_users`, etc.).
+//
+// Usage:
+// import { svcApi } from "@/lib/svc-fetch";
+// const { data, error } = await svcApi.from("admin_users").select("*");
+// const { data, error } = await svcApi.from("admin_users").insert({...}).select().single();
+
+class SvcPostgrestBuilder extends PostgrestBuilder {
+ constructor(table: string) {
+ super(table);
+ this.getHeaders = () => svcHeaders();
+ }
+}
+
+export const svcApi = {
+ from(table: TTable): SvcPostgrestBuilder> {
+ return new SvcPostgrestBuilder>(table);
+ },
+ rpc: svcRpc,
+};
diff --git a/supabase/captured/.gitkeep b/supabase/captured/.gitkeep
new file mode 100644
index 0000000..842285a
--- /dev/null
+++ b/supabase/captured/.gitkeep
@@ -0,0 +1 @@
+Captured Supabase dumps go here. Regenerate with docs/SUPABASE_DUMP_GUIDE.md
diff --git a/supabase/migrations/000_preflight_supabase_compat.sql b/supabase/migrations/000_preflight_supabase_compat.sql
new file mode 100644
index 0000000..ea29015
--- /dev/null
+++ b/supabase/migrations/000_preflight_supabase_compat.sql
@@ -0,0 +1,71 @@
+-- 000_preflight_supabase_compat.sql
+-- Stubs out Supabase-specific schemas/roles/functions so the
+-- Supabase-flavored migrations in this folder can apply to plain
+-- Postgres + PostgREST. Run FIRST.
+--
+-- In a real Supabase deployment, these are created automatically by
+-- the platform. Here we recreate the minimum surface area the rest
+-- of the migrations depend on.
+
+-- ── Extensions Supabase preinstalls ─────────────────────────────────
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- ── Roles Supabase normally provides ──────────────────────────────────
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
+ CREATE ROLE anon NOLOGIN;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
+ CREATE ROLE authenticated NOLOGIN;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
+ CREATE ROLE service_role NOLOGIN BYPASSRLS;
+ END IF;
+END
+$$;
+
+-- ── auth schema (Supabase Auth lives here; we just need uid() and friends)
+CREATE SCHEMA IF NOT EXISTS auth;
+
+-- auth.uid() — Supabase returns the user id from the JWT. In our self-hosted
+-- setup the app can SET LOCAL "request.jwt.claim.sub" = '' before
+-- calling PostgREST. Default to NULL for unauthenticated requests.
+CREATE OR REPLACE FUNCTION auth.uid()
+RETURNS UUID
+LANGUAGE sql
+STABLE
+AS $$
+ SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID
+$$;
+
+CREATE OR REPLACE FUNCTION auth.role()
+RETURNS TEXT
+LANGUAGE sql
+STABLE
+AS $$
+ SELECT COALESCE(NULLIF(current_setting('request.jwt.claim.role', true), ''), 'anon')
+$$;
+
+CREATE OR REPLACE FUNCTION auth.email()
+RETURNS TEXT
+LANGUAGE sql
+STABLE
+AS $$
+ SELECT NULLIF(current_setting('request.jwt.claim.email', true), '')
+$$;
+
+-- ── storage schema intentionally omitted ───────────────────────────
+-- We're replacing Supabase Storage with MinIO. The storage migrations
+-- (087, 099-contact-imports, 145) have been deleted from this folder.
+
+-- Grant the stub roles access to the public schema so RPCs can be called as them
+GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
+GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role;
+
+-- Make sure the routecommerce user can act as these roles (PostgREST will
+-- SET ROLE before executing requests, depending on the JWT).
+GRANT anon TO routecommerce;
+GRANT authenticated TO routecommerce;
+GRANT service_role TO routecommerce;
diff --git a/supabase/migrations/006_water_log_rpcs_fixed.sql b/supabase/migrations/006_water_log_rpcs_fixed.sql
index 2a713c0..79c05bf 100644
--- a/supabase/migrations/006_water_log_rpcs_fixed.sql
+++ b/supabase/migrations/006_water_log_rpcs_fixed.sql
@@ -25,7 +25,7 @@ DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
DECLARE
v_user jsonb;
@@ -84,7 +84,7 @@ CREATE OR REPLACE FUNCTION public.create_water_user(
)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
DECLARE
v_brand_id uuid;
@@ -125,7 +125,7 @@ COMMENT ON FUNCTION public.create_water_user IS
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
DECLARE
v_raw_pin text;
@@ -160,7 +160,7 @@ CREATE OR REPLACE FUNCTION public.submit_water_entry(
)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
DECLARE
v_sess record;
@@ -204,7 +204,7 @@ CREATE OR REPLACE FUNCTION public.update_water_user(
)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
BEGIN
UPDATE public.water_users
@@ -226,7 +226,7 @@ $$;
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
-STATIC
+STABLE
AS $$
BEGIN
RETURN (
diff --git a/supabase/migrations/087_brand_logos_bucket.sql b/supabase/migrations/087_brand_logos_bucket.sql
deleted file mode 100644
index 15a7d5a..0000000
--- a/supabase/migrations/087_brand_logos_bucket.sql
+++ /dev/null
@@ -1,77 +0,0 @@
--- Migration 087: Brand Logos Storage Bucket
---
--- SETUP REQUIRED (one-time, run manually in Supabase dashboard):
--- 1. Go to Storage → New bucket
--- 2. Name: "brand-logos" | Public: true
--- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml
--- 4. Max file size: 5MB
---
--- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png
-
--- RLS policies for brand-logos bucket
--- Brand admins can upload/update their own brand's logos
--- Everyone can read logos (public bucket)
-
-CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo"
-ON storage.objects
-FOR INSERT
-WITH CHECK (
- bucket_id = 'brand-logos'
- AND (
- -- Platform admin can upload to any brand
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
- )
- OR
- -- Brand admin can upload to their own brand's folder
- (storage.foldername(name))[1] IN (
- SELECT au.brand_id::text FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'brand_admin'
- )
- )
-);
-
-CREATE POLICY IF NOT EXISTS "public_read_logo"
-ON storage.objects
-FOR SELECT
-USING (bucket_id = 'brand-logos');
-
-CREATE POLICY IF NOT EXISTS "brand_admin_update_logo"
-ON storage.objects
-FOR UPDATE
-USING (
- bucket_id = 'brand-logos'
- AND (
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
- )
- OR
- (storage.foldername(name))[1] IN (
- SELECT au.brand_id::text FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'brand_admin'
- )
- )
-);
-
-CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo"
-ON storage.objects
-FOR DELETE
-USING (
- bucket_id = 'brand-logos'
- AND (
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
- )
- OR
- (storage.foldername(name))[1] IN (
- SELECT au.brand_id::text FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'brand_admin'
- )
- )
-);
\ No newline at end of file
diff --git a/supabase/migrations/099_contact_imports_bucket.sql b/supabase/migrations/099_contact_imports_bucket.sql
deleted file mode 100644
index b5e6ab2..0000000
--- a/supabase/migrations/099_contact_imports_bucket.sql
+++ /dev/null
@@ -1,36 +0,0 @@
--- Create imports bucket if it doesn't exist
--- Note: Run this in Supabase dashboard or via CLI
--- supabase storage create contacts-imports --public
-
--- Create RPC function to process imports from bucket URL
-CREATE OR REPLACE FUNCTION process_contact_import_from_url(
- p_brand_id UUID,
- p_file_url TEXT,
- p_allow_opt_in_override BOOLEAN DEFAULT false
-)
-RETURNS JSONB
-LANGUAGE plpgsql
-SECURITY DEFINER
-AS $$
-DECLARE
- v_result JSONB;
- v_csv_text TEXT;
-BEGIN
- -- Fetch the CSV from the bucket URL
- SELECT content INTO v_csv_text
- FROM net.http_get(p_file_url);
-
- -- Process the contacts (this would need to be implemented based on your CSV parsing logic)
- -- For now, return a placeholder result
- -- You would call your existing import logic here
-
- v_result := jsonb_build_object(
- 'created', 0,
- 'updated', 0,
- 'skipped', 0,
- 'errors', 0
- );
-
- RETURN v_result;
-END;
-$$;
\ No newline at end of file
diff --git a/supabase/migrations/135_email_automation_rpcs.sql b/supabase/migrations/135_email_automation_rpcs.sql
index 467f6de..208c16b 100644
--- a/supabase/migrations/135_email_automation_rpcs.sql
+++ b/supabase/migrations/135_email_automation_rpcs.sql
@@ -37,7 +37,7 @@ CREATE OR REPLACE FUNCTION enroll_abandoned_cart(
p_cart_snapshot JSONB,
p_brand_name TEXT,
p_locale TEXT DEFAULT 'en',
- p_next_email_at TIMESTAMPTZ
+ p_next_email_at TIMESTAMPTZ DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
diff --git a/supabase/migrations/145_create_product_images_bucket.sql b/supabase/migrations/145_create_product_images_bucket.sql
deleted file mode 100644
index 56885df..0000000
--- a/supabase/migrations/145_create_product_images_bucket.sql
+++ /dev/null
@@ -1,17 +0,0 @@
--- Create product-images bucket for product images (idempotent)
-INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
-SELECT 'product-images', 'product-images', true, 5242880, ARRAY['image/png', 'image/jpeg', 'image/webp']
-WHERE NOT EXISTS (
- SELECT 1 FROM storage.buckets WHERE id = 'product-images' OR name = 'product-images'
-);
-
--- Enable public access to product-images bucket (re-runnable)
-DROP POLICY IF EXISTS "Public can view product-images" ON storage.objects;
-CREATE POLICY "Public can view product-images"
-ON storage.objects FOR SELECT
-USING (bucket_id = 'product-images');
-
-DROP POLICY IF EXISTS "Admins can upload product-images" ON storage.objects;
-CREATE POLICY "Admins can upload product-images"
-ON storage.objects FOR INSERT
-WITH CHECK (bucket_id = 'product-images');
\ No newline at end of file
diff --git a/supabase/migrations/200_better_auth_tables.sql b/supabase/migrations/200_better_auth_tables.sql
new file mode 100644
index 0000000..bf367c9
--- /dev/null
+++ b/supabase/migrations/200_better_auth_tables.sql
@@ -0,0 +1,61 @@
+-- 200_better_auth_tables.sql
+-- Better Auth core tables: user, session, account, verification
+-- Replaces Supabase Auth for the self-hosted Postgres deployment.
+-- user.id is UUID (matches admin_users.user_id FK).
+
+CREATE TABLE IF NOT EXISTS "user" (
+ "id" UUID PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "email" TEXT NOT NULL UNIQUE,
+ "emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
+ "image" TEXT,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS "session" (
+ "id" UUID PRIMARY KEY,
+ "expiresAt" TIMESTAMPTZ NOT NULL,
+ "token" TEXT NOT NULL UNIQUE,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "ipAddress" TEXT,
+ "userAgent" TEXT,
+ "userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_session_userId" ON "session"("userId");
+
+CREATE TABLE IF NOT EXISTS "account" (
+ "id" UUID PRIMARY KEY,
+ "accountId" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
+ "accessToken" TEXT,
+ "refreshToken" TEXT,
+ "idToken" TEXT,
+ "accessTokenExpiresAt" TIMESTAMPTZ,
+ "refreshTokenExpiresAt" TIMESTAMPTZ,
+ "scope" TEXT,
+ "password" TEXT,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS "idx_account_userId" ON "account"("userId");
+CREATE INDEX IF NOT EXISTS "idx_account_providerId" ON "account"("providerId");
+
+CREATE TABLE IF NOT EXISTS "verification" (
+ "id" UUID PRIMARY KEY,
+ "identifier" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "expiresAt" TIMESTAMPTZ NOT NULL,
+ "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
+ "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS "idx_verification_identifier" ON "verification"("identifier");
+
+-- Better Auth does not need RLS on these tables — auth checks happen in app code.
+-- Disable RLS so existing SECURITY DEFINER RPCs can still read user rows if needed.
+ALTER TABLE "user" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE "session" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE "account" DISABLE ROW LEVEL SECURITY;
+ALTER TABLE "verification" DISABLE ROW LEVEL SECURITY;
diff --git a/supabase/migrations/BUNDLE_018_042.sql b/supabase/migrations/BUNDLE_018_042.sql
deleted file mode 100644
index b10b669..0000000
--- a/supabase/migrations/BUNDLE_018_042.sql
+++ /dev/null
@@ -1,4778 +0,0 @@
--- ───────────────────────────────────────────
--- 018_contact_import_metadata.sql
--- ───────────────────────────────────────────
--- =============================================================================
--- Communication Center V1.2 — Import Metadata Storage
--- Fully idempotent: additive changes only
--- Updates import_communication_contacts_batch to store ignored CSV columns
--- in metadata.imported_raw so no context is lost from arbitrary extra columns.
--- =============================================================================
-
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-
--- ── Update email path — add imported_raw to metadata on UPDATE ───────────────
--- Lines ~400-402 in migration 017: UPDATE path for existing email contact
-
-CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
- p_brand_id UUID,
- p_contacts JSONB,
- p_allow_opt_in_override BOOLEAN DEFAULT false
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_entry JSONB;
- v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
- v_existing communication_contacts%ROWTYPE;
- v_email TEXT;
- v_phone TEXT;
- v_tags TEXT[];
- v_raw_meta JSONB;
-BEGIN
- FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
- LOOP
- v_email := nullif(v_entry->>'email', '');
- v_phone := nullif(v_entry->>'phone', '');
-
- -- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
- IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
- v_tags := coalesce(
- (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
- '{}'
- );
- ELSE
- v_tags := '{}';
- END IF;
-
- -- Build imported_raw from any _metadata key (ignored CSV columns)
- v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB;
-
- BEGIN
- IF v_email IS NOT NULL AND v_email != '' THEN
- SELECT * INTO v_existing
- FROM public.communication_contacts
- WHERE brand_id = p_brand_id AND email = v_email;
-
- IF FOUND THEN
- -- Existing contact
- IF v_existing.unsubscribed_at IS NOT NULL AND
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
- p_allow_opt_in_override = false THEN
- v_result := jsonb_set(v_result, '{skipped}',
- to_jsonb((v_result->>'skipped')::INTEGER + 1));
- ELSE
- UPDATE public.communication_contacts SET
- phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
- first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
- last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
- full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
- source = 'import',
- external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
- email_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.email_opt_in
- ELSE coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- )
- END,
- sms_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.sms_opt_in
- ELSE coalesce(
- (v_entry->>'sms_opt_in')::BOOLEAN,
- communication_contacts.sms_opt_in,
- false
- )
- END,
- email_opt_in_at = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN email_opt_in_at
- WHEN coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- ) = true THEN now()
- ELSE email_opt_in_at
- END,
- tags = CASE
- WHEN v_tags != '{}' THEN v_tags
- ELSE communication_contacts.tags
- END,
- metadata = communication_contacts.metadata ||
- jsonb_build_object(
- 'imported_at', now()::TEXT,
- 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
- ),
- updated_at = now()
- WHERE brand_id = p_brand_id AND email = v_email;
-
- v_result := jsonb_set(v_result, '{updated}',
- to_jsonb((v_result->>'updated')::INTEGER + 1));
- END IF;
-
- ELSE
- -- Insert new
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, first_name, last_name, full_name, source,
- external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
- tags, metadata)
- VALUES (
- p_brand_id,
- v_email,
- nullif(v_entry->>'phone', ''),
- nullif(v_entry->>'first_name', ''),
- nullif(v_entry->>'last_name', ''),
- nullif(v_entry->>'full_name', ''),
- 'import',
- nullif(v_entry->>'external_id', ''),
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
- coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
- CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
- CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
- v_tags,
- jsonb_build_object(
- 'imported_at', now()::TEXT,
- 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
- )
- );
- v_result := jsonb_set(v_result, '{created}',
- to_jsonb((v_result->>'created')::INTEGER + 1));
- END IF;
-
- ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
- -- Upsert by phone
- SELECT * INTO v_existing
- FROM public.communication_contacts
- WHERE brand_id = p_brand_id AND phone = v_phone;
-
- IF FOUND THEN
- IF v_existing.unsubscribed_at IS NOT NULL AND
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
- p_allow_opt_in_override = false THEN
- v_result := jsonb_set(v_result, '{skipped}',
- to_jsonb((v_result->>'skipped')::INTEGER + 1));
- ELSE
- UPDATE public.communication_contacts SET
- email = COALESCE(nullif(v_entry->>'email', ''), email),
- first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
- last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
- full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
- source = 'import',
- email_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.email_opt_in
- ELSE coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- )
- END,
- tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
- metadata = communication_contacts.metadata ||
- jsonb_build_object(
- 'imported_at', now()::TEXT,
- 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
- ),
- updated_at = now()
- WHERE brand_id = p_brand_id AND phone = v_phone;
-
- v_result := jsonb_set(v_result, '{updated}',
- to_jsonb((v_result->>'updated')::INTEGER + 1));
- END IF;
- ELSE
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, first_name, last_name, full_name, source,
- email_opt_in, sms_opt_in, tags, metadata)
- VALUES (
- p_brand_id,
- nullif(v_entry->>'email', ''),
- v_phone,
- nullif(v_entry->>'first_name', ''),
- nullif(v_entry->>'last_name', ''),
- nullif(v_entry->>'full_name', ''),
- 'import',
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
- coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
- v_tags,
- jsonb_build_object(
- 'imported_at', now()::TEXT,
- 'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
- )
- );
- v_result := jsonb_set(v_result, '{created}',
- to_jsonb((v_result->>'created')::INTEGER + 1));
- END IF;
-
- ELSE
- -- No email or phone
- v_result := jsonb_set(
- v_result, '{errors}',
- v_result->'errors' || jsonb_build_array(
- jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
- )
- );
- END IF;
-
- EXCEPTION WHEN OTHERS THEN
- v_result := jsonb_set(
- v_result, '{errors}',
- v_result->'errors' || jsonb_build_array(
- jsonb_build_object('row', v_entry, 'error', SQLERRM)
- )
- );
- END;
- END LOOP;
-
- RETURN v_result;
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 019_customers_table.sql
--- ───────────────────────────────────────────
--- =============================================================================
--- V1.2 Stage 1 — Canonical Customers Table
--- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
--- Purely additive: no changes to orders, communication_contacts, or checkout
---
--- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert:
--- - email: lowercase + trim
--- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain
--- This ensures consistent matching across orders, imports, and contacts.
--- =============================================================================
-
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-
--- ── Table ────────────────────────────────────────────────────────────────────
-
-CREATE TABLE IF NOT EXISTS public.customers (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
- primary_email TEXT,
- primary_phone TEXT,
- first_name TEXT,
- last_name TEXT,
- source TEXT NOT NULL DEFAULT 'system',
- metadata JSONB NOT NULL DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- CONSTRAINT customers_email_or_phone CHECK (
- primary_email IS NOT NULL OR primary_phone IS NOT NULL
- )
-);
-
--- ── Indexes ───────────────────────────────────────────────────────────────────
-
-CREATE INDEX IF NOT EXISTS idx_customers_brand
- ON public.customers(brand_id);
-CREATE INDEX IF NOT EXISTS idx_customers_email
- ON public.customers(primary_email) WHERE primary_email IS NOT NULL;
-CREATE INDEX IF NOT EXISTS idx_customers_phone
- ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL;
-
--- Partial unique indexes: enforce one customer per brand per email/phone
--- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique
-CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique
- ON public.customers(brand_id, primary_email)
- WHERE primary_email IS NOT NULL;
-
-CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique
- ON public.customers(brand_id, primary_phone)
- WHERE primary_phone IS NOT NULL;
-
--- ── RLS ───────────────────────────────────────────────────────────────────────
-
-ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS "Brand admin can read customers"
- ON public.customers;
-CREATE POLICY "Brand admin can read customers"
- ON public.customers FOR SELECT TO authenticated
- USING (brand_id IN (
- SELECT brand_id FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'brand_admin'
- ));
-
-DROP POLICY IF EXISTS "Platform admin can read customers"
- ON public.customers;
-CREATE POLICY "Platform admin can read customers"
- ON public.customers FOR SELECT TO authenticated
- USING (EXISTS (
- SELECT 1 FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'platform_admin'
- ));
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 020_order_customer_linking.sql
--- ───────────────────────────────────────────
--- =============================================================================
--- V1.2 Stage 2 — Order/Contact/Customer Linking
--- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations
--- Purely additive behavior: no changes to orders schema, no FKs, no backfill
---
--- DEPENDENCY: Requires 019_customers_table.sql to be applied first.
--- Raises an exception if the customers table does not exist.
---
--- TRANSITIONAL ARCHITECTURE NOTE:
--- orders.customer_id and customers.id are separate ID namespaces today.
--- communication_contacts.customer_id links to customers.id for new order-created
--- contacts (via this migration), but orders.customer_id is NOT yet aligned.
---
--- A future stage (Stage 3+) should consider:
--- - backfilling customers from existing orders/communication_contacts
--- - aligning orders.customer_id to canonical customers.id
--- - adding proper FK constraints only after data is cleaned
--- - avoiding permanent dual customer ID namespaces
---
--- STAGE 2 NORMALIZATION:
--- email: trim(lower(...)) before match/upsert
--- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert
--- This is consistent with frontend normalizeEmail/normalizePhone.
--- =============================================================================
-
--- ── Dependency guard ─────────────────────────────────────────────────────────
-DO $$
-BEGIN
- IF to_regclass('public.customers') IS NULL THEN
- RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql';
- END IF;
-END;
-$$;
-
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-
--- ── Safe teardown order: trigger → functions ────────────────────────────────
--- PostgreSQL requires dropping a trigger before dropping the function it uses.
--- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger)
-
-DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
-DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT);
-DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
-
--- ─────────────────────────────────────────────────────────────────────────────
--- upsert_customer_from_order
---
--- Resolves or creates a canonical customers record for a given order.
--- Returns the customers.id for linking, or NULL if no contact method exists.
---
--- Upsert order: email first → phone fallback (email is more stable identity)
--- Brand separation: brand_id is the scoping key throughout.
--- ─────────────────────────────────────────────────────────────────────────────
-
-CREATE OR REPLACE FUNCTION public.upsert_customer_from_order(
- p_brand_id UUID,
- p_customer_email TEXT,
- p_customer_phone TEXT,
- p_customer_name TEXT,
- p_source TEXT DEFAULT 'order'
-)
-RETURNS UUID -- customers.id
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_norm_email TEXT := trim(lower(p_customer_email));
- v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g');
- v_cust_id UUID;
-BEGIN
- -- No email AND no phone: nothing to upsert
- IF (v_norm_email IS NULL OR v_norm_email = '') AND
- (v_norm_phone IS NULL OR v_norm_phone = '') THEN
- RETURN NULL;
- END IF;
-
- -- Try email match first (within brand)
- IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN
- SELECT id INTO v_cust_id
- FROM public.customers
- WHERE brand_id = p_brand_id
- AND primary_email = v_norm_email;
-
- IF FOUND THEN
- UPDATE public.customers SET
- primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone),
- first_name = COALESCE(
- NULLIF(split_part(p_customer_name, ' ', 1), ''),
- customers.first_name
- ),
- last_name = COALESCE(
- NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
- for length(p_customer_name)), ''),
- customers.last_name
- ),
- source = CASE
- WHEN customers.source = 'system' THEN p_source ELSE customers.source
- END,
- updated_at = now()
- WHERE id = v_cust_id;
- RETURN v_cust_id;
- END IF;
- END IF;
-
- -- Phone fallback (only if email didn't find a match)
- IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN
- SELECT id INTO v_cust_id
- FROM public.customers
- WHERE brand_id = p_brand_id
- AND primary_phone = v_norm_phone;
-
- IF FOUND THEN
- UPDATE public.customers SET
- primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email),
- first_name = COALESCE(
- NULLIF(split_part(p_customer_name, ' ', 1), ''),
- customers.first_name
- ),
- last_name = COALESCE(
- NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
- for length(p_customer_name)), ''),
- customers.last_name
- ),
- source = CASE
- WHEN customers.source = 'system' THEN p_source ELSE customers.source
- END,
- updated_at = now()
- WHERE id = v_cust_id;
- RETURN v_cust_id;
- END IF;
- END IF;
-
- -- Insert new customer
- INSERT INTO public.customers
- (brand_id, primary_email, primary_phone, first_name, last_name, source)
- VALUES (
- p_brand_id,
- NULLIF(v_norm_email, ''),
- NULLIF(v_norm_phone, ''),
- NULLIF(split_part(p_customer_name, ' ', 1), ''),
- NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
- for length(p_customer_name)), ''),
- p_source
- )
- RETURNING id INTO v_cust_id;
-
- RETURN v_cust_id;
-END;
-$$;
-
--- ─────────────────────────────────────────────────────────────────────────────
--- upsert_contact_from_order trigger
---
--- NOW DOES TWO THINGS:
--- 1. Call upsert_customer_from_order() to get/create customers.id
--- 2. Use that customers.id as communication_contacts.customer_id (linking)
---
--- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT
--- updated on conflict — this behavior is unchanged from V1.1.
--- ─────────────────────────────────────────────────────────────────────────────
-
-CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
-RETURNS TRIGGER
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_brand_id UUID;
- v_customer_id UUID;
-BEGIN
- -- Resolve brand_id from the stop
- SELECT brand_id INTO v_brand_id
- FROM public.stops
- WHERE id = NEW.stop_id;
- IF NOT FOUND THEN
- RETURN NEW;
- END IF;
-
- -- Step 1: upsert canonical customer (NEW.customer_id not referenced —
- -- orders table does not have that column; use only email/phone/name)
- v_customer_id := upsert_customer_from_order(
- v_brand_id,
- NEW.customer_email,
- NEW.customer_phone,
- NEW.customer_name,
- 'order'
- );
-
- -- Step 2: upsert communication contact, linked to customers.id
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
- VALUES (
- v_brand_id,
- NEW.customer_email,
- NEW.customer_phone,
- NEW.customer_name,
- 'order',
- v_customer_id,
- true,
- jsonb_build_object(
- 'last_order_id', NEW.id,
- 'last_order_at', now()::TEXT,
- 'stop_id', NEW.stop_id::TEXT
- )
- )
- ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
- full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
- phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
- customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
- metadata = jsonb_build_object(
- 'last_order_id', communication_contacts.metadata->>'last_order_id',
- 'last_order_at', communication_contacts.metadata->>'last_order_at',
- 'stop_id', NEW.stop_id::TEXT
- ),
- updated_at = now();
- -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
-
- RETURN NEW;
-END;
-$$;
-
--- ── Recreate trigger ─────────────────────────────────────────────────────────
-CREATE TRIGGER trg_create_contact_from_order
- AFTER INSERT ON public.orders
- FOR EACH ROW
- EXECUTE FUNCTION public.upsert_contact_from_order();
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 021_shipping_only_brand.sql
--- ───────────────────────────────────────────
--- =============================================================================
--- V1.2 Stage 3 — Shipping-Only Brand Resolution
--- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS
---
--- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first.
--- Raises an exception if the trigger function does not exist.
---
--- CHANGES:
--- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id)
--- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only
--- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id
--- first, falling back to stop lookup — supports both pickup and shipping orders
--- =============================================================================
-
-DO $$
-BEGIN
- IF NOT EXISTS (
- SELECT 1
- FROM pg_proc p
- JOIN pg_namespace n ON n.oid = p.pronamespace
- WHERE n.nspname = 'public'
- AND p.proname = 'upsert_contact_from_order'
- ) THEN
- RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql';
- END IF;
-END;
-$$;
-
--- ── 1. Add brand_id to orders ────────────────────────────────────────────────
--- Allows the order/contact trigger to resolve brand without needing a stop_id.
--- For shipping-only orders, brand is derived from the first product.
-
-ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID;
-
--- ── 2. Modify create_order_with_items for shipping-only ────────────────────
--- stop_id is now nullable. When NULL (shipping-only), brand is derived from
--- the first product's brand and no stop validation occurs.
-
-CREATE OR REPLACE FUNCTION create_order_with_items(
- p_idempotency_key UUID,
- p_customer_name TEXT,
- p_customer_email TEXT,
- p_customer_phone TEXT,
- p_stop_id UUID, -- nullable for shipping-only
- p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
-)
-RETURNS JSONB
-LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_order_id UUID;
- v_item JSONB;
- v_product_id UUID;
- v_quantity INT;
- v_fulfillment TEXT;
- v_product_price NUMERIC;
- v_computed_total NUMERIC := 0;
- v_stop_active BOOLEAN;
- v_stop_brand_id UUID;
- v_stop_city TEXT;
- v_stop_state TEXT;
- v_stop_date TEXT;
- v_stop_time TEXT;
- v_stop_location TEXT;
- v_product_active BOOLEAN;
- v_product_brand_id UUID;
- v_product_name TEXT;
- v_is_pickup BOOLEAN;
- v_order JSONB;
- v_order_items JSONB := '[]'::JSONB;
- v_brand_id UUID;
-BEGIN
- -- ── Idempotency guard ─────────────────────────────────────────────────────
- SELECT jsonb_build_object(
- 'id', o.id,
- 'customer_name', o.customer_name,
- 'customer_email', o.customer_email,
- 'customer_phone', o.customer_phone,
- 'subtotal', o.subtotal,
- 'status', o.status,
- 'stop_id', o.stop_id,
- 'brand_id', o.brand_id,
- 'stop_city', s.city,
- 'stop_state', s.state,
- 'stop_date', s.date,
- 'stop_time', s.time,
- 'stop_location', s.location,
- 'items', COALESCE((
- SELECT jsonb_agg(jsonb_build_object(
- 'product_id', oi.product_id,
- 'product_name', p.name,
- 'quantity', oi.quantity,
- 'price', oi.price,
- 'fulfillment', oi.fulfillment
- ))
- FROM order_items oi
- JOIN products p ON oi.product_id = p.id
- WHERE oi.order_id = o.id
- ), '[]'::JSONB)
- )
- INTO v_order
- FROM orders o
- LEFT JOIN stops s ON o.stop_id = s.id
- WHERE o.idempotency_key = p_idempotency_key
- LIMIT 1;
-
- IF v_order IS NOT NULL THEN
- RETURN v_order;
- END IF;
-
- -- ── Resolve brand_id ──────────────────────────────────────────────────────
- -- For shipping-only orders (p_stop_id IS NULL), derive from the first product.
- -- For pickup orders, derive from the stop.
- IF p_stop_id IS NOT NULL THEN
- SELECT active, brand_id, city, state, date, time, location
- INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
- FROM stops
- WHERE id = p_stop_id;
-
- IF v_stop_brand_id IS NULL THEN
- RAISE EXCEPTION 'Stop not found: %', p_stop_id;
- END IF;
-
- IF NOT v_stop_active THEN
- RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
- END IF;
-
- v_brand_id := v_stop_brand_id;
- ELSE
- -- Shipping-only: resolve brand from first product in the order
- v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
-
- SELECT brand_id INTO v_brand_id
- FROM products
- WHERE id = v_product_id;
-
- IF v_brand_id IS NULL THEN
- RAISE EXCEPTION 'Product not found: %', v_product_id;
- END IF;
- END IF;
-
- -- ── Validate items and compute subtotal ─────────────────────────────────
- FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
- LOOP
- v_product_id := (v_item->>'id')::UUID;
- v_quantity := (v_item->>'quantity')::INT;
- v_fulfillment := v_item->>'fulfillment';
- v_is_pickup := (v_fulfillment = 'pickup');
-
- SELECT active, brand_id, price, name
- INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
- FROM products
- WHERE id = v_product_id;
-
- IF v_product_brand_id IS NULL THEN
- RAISE EXCEPTION 'Product not found: %', v_product_id;
- END IF;
-
- IF v_product_brand_id != v_brand_id THEN
- RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
- END IF;
-
- IF NOT v_product_active THEN
- RAISE EXCEPTION 'Product is not active: %', v_product_id;
- END IF;
-
- IF v_is_pickup THEN
- -- Pickup items require a valid stop assignment
- IF p_stop_id IS NULL THEN
- RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
- END IF;
-
- PERFORM 1
- FROM product_stops
- WHERE product_id = v_product_id AND stop_id = p_stop_id
- LIMIT 1;
-
- IF NOT FOUND THEN
- RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
- END IF;
- END IF;
-
- v_computed_total := v_computed_total + (v_product_price * v_quantity);
- END LOOP;
-
- -- ── Create order (with brand_id) ─────────────────────────────────────────
- INSERT INTO orders (
- idempotency_key, customer_name, customer_email, customer_phone,
- stop_id, brand_id, subtotal, status
- ) VALUES (
- p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
- p_stop_id, v_brand_id, v_computed_total, 'pending'
- )
- RETURNING id INTO v_order_id;
-
- -- ── Insert order items and build return value ─────────────────────────────
- FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
- LOOP
- v_product_id := (v_item->>'id')::UUID;
- v_quantity := (v_item->>'quantity')::INT;
- v_fulfillment := v_item->>'fulfillment';
-
- SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
-
- INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
- VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
-
- v_order_items := v_order_items || jsonb_build_object(
- 'product_id', v_product_id,
- 'product_name', v_product_name,
- 'quantity', v_quantity,
- 'price', v_product_price,
- 'fulfillment', v_fulfillment
- );
- END LOOP;
-
- -- ── Build and return full order object ───────────────────────────────────
- RETURN jsonb_build_object(
- 'id', v_order_id,
- 'customer_name', p_customer_name,
- 'customer_email', p_customer_email,
- 'customer_phone', p_customer_phone,
- 'subtotal', v_computed_total,
- 'status', 'pending',
- 'stop_id', p_stop_id,
- 'brand_id', v_brand_id,
- 'stop_city', v_stop_city,
- 'stop_state', v_stop_state,
- 'stop_date', v_stop_date,
- 'stop_time', v_stop_time,
- 'stop_location', v_stop_location,
- 'items', v_order_items
- );
-END;
-$$;
-
--- ── 3. Update trigger to resolve brand from NEW.brand_id first ───────────────
--- NEW.brand_id is the primary source (always set by create_order_with_items).
--- Stop lookup is the fallback (for pre-existing orders that lack brand_id).
-
-DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
-DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
-
-CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
-RETURNS TRIGGER
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_brand_id UUID;
- v_customer_id UUID;
-BEGIN
- -- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+)
- IF NEW.brand_id IS NOT NULL THEN
- v_brand_id := NEW.brand_id;
- ELSE
- -- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id)
- SELECT brand_id INTO v_brand_id
- FROM public.stops
- WHERE id = NEW.stop_id;
- IF NOT FOUND THEN
- RETURN NEW; -- cannot resolve brand: skip customer/contact linking
- END IF;
- END IF;
-
- -- No email AND no phone: nothing to upsert
- IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND
- (NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN
- RETURN NEW;
- END IF;
-
- -- Step 1: upsert canonical customer
- v_customer_id := upsert_customer_from_order(
- v_brand_id,
- NEW.customer_email,
- NEW.customer_phone,
- NEW.customer_name,
- 'order'
- );
-
- -- Step 2: upsert communication contact, linked to customers.id
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
- VALUES (
- v_brand_id,
- NEW.customer_email,
- NEW.customer_phone,
- NEW.customer_name,
- 'order',
- v_customer_id,
- true,
- jsonb_build_object(
- 'last_order_id', NEW.id,
- 'last_order_at', now()::TEXT,
- 'stop_id', NEW.stop_id::TEXT
- )
- )
- ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
- full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
- phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
- customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
- metadata = jsonb_build_object(
- 'last_order_id', communication_contacts.metadata->>'last_order_id',
- 'last_order_at', communication_contacts.metadata->>'last_order_at',
- 'stop_id', NEW.stop_id::TEXT
- ),
- updated_at = now();
- -- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
-
- RETURN NEW;
-END;
-$$;
-
-CREATE TRIGGER trg_create_contact_from_order
- AFTER INSERT ON public.orders
- FOR EACH ROW
- EXECUTE FUNCTION public.upsert_contact_from_order();
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 022_operational_events.sql
--- ───────────────────────────────────────────
--- ─────────────────────────────────────────────────────────────────────────────
--- Migration 022: Operational Events
--- ─────────────────────────────────────────────────────────────────────────────
--- Append-only event layer for recording important platform actions.
--- Scope: table + indexes + RLS + helpers + 4 initial emitters.
--- What NOT included: automation, queues, webhooks, retries, cron jobs.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. operational_events table
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE TABLE IF NOT EXISTS public.operational_events (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
- event_type TEXT NOT NULL,
- entity_type TEXT,
- entity_id UUID,
- actor_type TEXT,
- actor_id UUID,
- source TEXT NOT NULL DEFAULT 'system',
- payload JSONB NOT NULL DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-
- CONSTRAINT operational_events_event_type CHECK (
- event_type ~ '^[a-z][a-z0-9_]*$'
- )
-);
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. Indexes
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id);
-CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type);
-CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id);
-CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC);
-CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC);
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. RLS policies
--- ═══════════════════════════════════════════════════════════════════════════
-
-ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events;
-CREATE POLICY "Brand admin can read operational_events"
- ON operational_events FOR SELECT TO authenticated
- USING (
- brand_id IN (
- SELECT brand_id FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'brand_admin'
- )
- );
-
-DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events;
-CREATE POLICY "Platform admin can read operational_events"
- ON operational_events FOR SELECT TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'platform_admin'
- )
- );
-
--- Writes go through SECURITY DEFINER helpers only.
--- No INSERT granted to authenticated roles.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. record_operational_event helper — bypasses RLS, all emitters call this
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.record_operational_event(
- p_brand_id UUID,
- p_event_type TEXT,
- p_entity_type TEXT,
- p_entity_id UUID,
- p_actor_type TEXT,
- p_actor_id UUID,
- p_source TEXT,
- p_payload JSONB
-)
-RETURNS void
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- INSERT INTO operational_events (
- brand_id, event_type, entity_type, entity_id,
- actor_type, actor_id, source, payload
- ) VALUES (
- p_brand_id, p_event_type, p_entity_type, p_entity_id,
- p_actor_type, p_actor_id, p_source, p_payload
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 5. record_pickup_completed_event helper — called from TypeScript action
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.record_pickup_completed_event(
- p_order_id UUID,
- p_brand_id UUID,
- p_actor_id UUID
-)
-RETURNS void
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_order_data JSONB;
-BEGIN
- SELECT jsonb_build_object(
- 'subtotal', subtotal,
- 'customer_name', customer_name
- ) INTO v_order_data
- FROM orders
- WHERE id = p_order_id;
-
- PERFORM record_operational_event(
- p_brand_id,
- 'pickup_completed',
- 'order',
- p_order_id,
- 'admin',
- p_actor_id,
- 'system',
- coalesce(v_order_data, '{}'::JSONB)
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 6. create_order_with_items — with order_placed emitter
--- (explicit replace; same signature as migration 021)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION create_order_with_items(
- p_idempotency_key UUID,
- p_customer_name TEXT,
- p_customer_email TEXT,
- p_customer_phone TEXT,
- p_stop_id UUID,
- p_items JSONB
-)
-RETURNS JSONB
-LANGUAGE plpgsql
-SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_order_id UUID;
- v_item JSONB;
- v_product_id UUID;
- v_quantity INT;
- v_fulfillment TEXT;
- v_product_price NUMERIC;
- v_computed_total NUMERIC := 0;
- v_stop_active BOOLEAN;
- v_stop_brand_id UUID;
- v_stop_city TEXT;
- v_stop_state TEXT;
- v_stop_date TEXT;
- v_stop_time TEXT;
- v_stop_location TEXT;
- v_product_active BOOLEAN;
- v_product_brand_id UUID;
- v_product_name TEXT;
- v_is_pickup BOOLEAN;
- v_has_pickup BOOLEAN := false;
- v_order JSONB;
- v_order_items JSONB := '[]'::JSONB;
- v_brand_id UUID;
-BEGIN
- -- ── Idempotency guard ─────────────────────────────────────────────────────
- SELECT jsonb_build_object(
- 'id', o.id,
- 'customer_name', o.customer_name,
- 'customer_email', o.customer_email,
- 'customer_phone', o.customer_phone,
- 'subtotal', o.subtotal,
- 'status', o.status,
- 'stop_id', o.stop_id,
- 'brand_id', o.brand_id,
- 'stop_city', s.city,
- 'stop_state', s.state,
- 'stop_date', s.date,
- 'stop_time', s.time,
- 'stop_location', s.location,
- 'items', COALESCE((
- SELECT jsonb_agg(jsonb_build_object(
- 'product_id', oi.product_id,
- 'product_name', p.name,
- 'quantity', oi.quantity,
- 'price', oi.price,
- 'fulfillment', oi.fulfillment
- ))
- FROM order_items oi
- JOIN products p ON oi.product_id = p.id
- WHERE oi.order_id = o.id
- ), '[]'::JSONB)
- )
- INTO v_order
- FROM orders o
- LEFT JOIN stops s ON o.stop_id = s.id
- WHERE o.idempotency_key = p_idempotency_key
- LIMIT 1;
-
- IF v_order IS NOT NULL THEN
- RETURN v_order;
- END IF;
-
- -- ── Resolve brand_id ──────────────────────────────────────────────────────
- IF p_stop_id IS NOT NULL THEN
- SELECT active, brand_id, city, state, date, time, location
- INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
- FROM stops
- WHERE id = p_stop_id;
-
- IF v_stop_brand_id IS NULL THEN
- RAISE EXCEPTION 'Stop not found: %', p_stop_id;
- END IF;
-
- IF NOT v_stop_active THEN
- RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
- END IF;
-
- v_brand_id := v_stop_brand_id;
- ELSE
- v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
-
- SELECT brand_id INTO v_brand_id
- FROM products
- WHERE id = v_product_id;
-
- IF v_brand_id IS NULL THEN
- RAISE EXCEPTION 'Product not found: %', v_product_id;
- END IF;
- END IF;
-
- -- ── Validate items and compute subtotal ─────────────────────────────────
- FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
- LOOP
- v_product_id := (v_item->>'id')::UUID;
- v_quantity := (v_item->>'quantity')::INT;
- v_fulfillment := v_item->>'fulfillment';
- v_is_pickup := (v_fulfillment = 'pickup');
-
- IF v_is_pickup THEN
- v_has_pickup := true;
- END IF;
-
- SELECT active, brand_id, price, name
- INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
- FROM products
- WHERE id = v_product_id;
-
- IF v_product_brand_id IS NULL THEN
- RAISE EXCEPTION 'Product not found: %', v_product_id;
- END IF;
-
- IF v_product_brand_id != v_brand_id THEN
- RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
- END IF;
-
- IF NOT v_product_active THEN
- RAISE EXCEPTION 'Product is not active: %', v_product_id;
- END IF;
-
- IF v_is_pickup THEN
- IF p_stop_id IS NULL THEN
- RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
- END IF;
-
- PERFORM 1
- FROM product_stops
- WHERE product_id = v_product_id AND stop_id = p_stop_id
- LIMIT 1;
-
- IF NOT FOUND THEN
- RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
- END IF;
- END IF;
-
- v_computed_total := v_computed_total + (v_product_price * v_quantity);
- END LOOP;
-
- -- ── Create order (with brand_id) ─────────────────────────────────────────
- INSERT INTO orders (
- idempotency_key, customer_name, customer_email, customer_phone,
- stop_id, brand_id, subtotal, status
- ) VALUES (
- p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
- p_stop_id, v_brand_id, v_computed_total, 'pending'
- )
- RETURNING id INTO v_order_id;
-
- -- ── Insert order items and build return value ─────────────────────────────
- FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
- LOOP
- v_product_id := (v_item->>'id')::UUID;
- v_quantity := (v_item->>'quantity')::INT;
- v_fulfillment := v_item->>'fulfillment';
-
- SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
-
- INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
- VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
-
- v_order_items := v_order_items || jsonb_build_object(
- 'product_id', v_product_id,
- 'product_name', v_product_name,
- 'quantity', v_quantity,
- 'price', v_product_price,
- 'fulfillment', v_fulfillment
- );
- END LOOP;
-
- -- ── Emit order_placed event ───────────────────────────────────────────────
- PERFORM record_operational_event(
- v_brand_id,
- 'order_placed',
- 'order',
- v_order_id,
- 'customer',
- NULL,
- 'system',
- jsonb_build_object(
- 'subtotal', v_computed_total,
- 'item_count', jsonb_array_length(p_items),
- 'has_pickup', v_has_pickup
- )
- );
-
- -- ── Build and return full order object ───────────────────────────────────
- RETURN jsonb_build_object(
- 'id', v_order_id,
- 'customer_name', p_customer_name,
- 'customer_email', p_customer_email,
- 'customer_phone', p_customer_phone,
- 'subtotal', v_computed_total,
- 'status', 'pending',
- 'stop_id', p_stop_id,
- 'brand_id', v_brand_id,
- 'stop_city', v_stop_city,
- 'stop_state', v_stop_state,
- 'stop_date', v_stop_date,
- 'stop_time', v_stop_time,
- 'stop_location', v_stop_location,
- 'items', v_order_items
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 7. import_communication_contacts_batch — with contact_imported emitter
--- (explicit replace; same signature as migrations 017/018)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
- p_brand_id UUID,
- p_contacts JSONB,
- p_allow_opt_in_override BOOLEAN DEFAULT false
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_entry JSONB;
- v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
- v_existing communication_contacts%ROWTYPE;
- v_email TEXT;
- v_phone TEXT;
- v_tags TEXT[];
-BEGIN
- FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
- LOOP
- v_email := nullif(v_entry->>'email', '');
- v_phone := nullif(v_entry->>'phone', '');
-
- IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
- v_tags := coalesce(
- (SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
- '{}'
- );
- ELSE
- v_tags := '{}';
- END IF;
-
- BEGIN
- IF v_email IS NOT NULL AND v_email != '' THEN
- SELECT * INTO v_existing
- FROM public.communication_contacts
- WHERE brand_id = p_brand_id AND email = v_email;
-
- IF FOUND THEN
- IF v_existing.unsubscribed_at IS NOT NULL AND
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
- p_allow_opt_in_override = false THEN
- v_result := jsonb_set(v_result, '{skipped}',
- to_jsonb((v_result->>'skipped')::INTEGER + 1));
- ELSE
- UPDATE public.communication_contacts SET
- phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
- first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
- last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
- full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
- source = 'import',
- external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
- email_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.email_opt_in
- ELSE coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- )
- END,
- sms_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.sms_opt_in
- ELSE coalesce(
- (v_entry->>'sms_opt_in')::BOOLEAN,
- communication_contacts.sms_opt_in,
- false
- )
- END,
- email_opt_in_at = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN email_opt_in_at
- WHEN coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- ) = true THEN now()
- ELSE email_opt_in_at
- END,
- tags = CASE
- WHEN v_tags != '{}' THEN v_tags
- ELSE communication_contacts.tags
- END,
- metadata = communication_contacts.metadata || jsonb_build_object(
- 'imported_at', now()::TEXT
- ),
- updated_at = now()
- WHERE brand_id = p_brand_id AND email = v_email;
-
- v_result := jsonb_set(v_result, '{updated}',
- to_jsonb((v_result->>'updated')::INTEGER + 1));
- END IF;
-
- ELSE
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, first_name, last_name, full_name, source,
- external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
- tags, metadata)
- VALUES (
- p_brand_id,
- v_email,
- nullif(v_entry->>'phone', ''),
- nullif(v_entry->>'first_name', ''),
- nullif(v_entry->>'last_name', ''),
- nullif(v_entry->>'full_name', ''),
- 'import',
- nullif(v_entry->>'external_id', ''),
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
- coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
- CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
- CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
- v_tags,
- jsonb_build_object('imported_at', now()::TEXT)
- );
- v_result := jsonb_set(v_result, '{created}',
- to_jsonb((v_result->>'created')::INTEGER + 1));
- END IF;
-
- ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
- SELECT * INTO v_existing
- FROM public.communication_contacts
- WHERE brand_id = p_brand_id AND phone = v_phone;
-
- IF FOUND THEN
- IF v_existing.unsubscribed_at IS NOT NULL AND
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
- p_allow_opt_in_override = false THEN
- v_result := jsonb_set(v_result, '{skipped}',
- to_jsonb((v_result->>'skipped')::INTEGER + 1));
- ELSE
- UPDATE public.communication_contacts SET
- email = COALESCE(nullif(v_entry->>'email', ''), email),
- first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
- last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
- full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
- source = 'import',
- email_opt_in = CASE
- WHEN communication_contacts.unsubscribed_at IS NOT NULL
- THEN communication_contacts.email_opt_in
- ELSE coalesce(
- (v_entry->>'email_opt_in')::BOOLEAN,
- communication_contacts.email_opt_in,
- true
- )
- END,
- tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
- metadata = communication_contacts.metadata || jsonb_build_object(
- 'imported_at', now()::TEXT
- ),
- updated_at = now()
- WHERE brand_id = p_brand_id AND phone = v_phone;
-
- v_result := jsonb_set(v_result, '{updated}',
- to_jsonb((v_result->>'updated')::INTEGER + 1));
- END IF;
- ELSE
- INSERT INTO public.communication_contacts
- (brand_id, email, phone, first_name, last_name, full_name, source,
- email_opt_in, sms_opt_in, tags, metadata)
- VALUES (
- p_brand_id,
- nullif(v_entry->>'email', ''),
- v_phone,
- nullif(v_entry->>'first_name', ''),
- nullif(v_entry->>'last_name', ''),
- nullif(v_entry->>'full_name', ''),
- 'import',
- coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
- coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
- v_tags,
- jsonb_build_object('imported_at', now()::TEXT)
- );
- v_result := jsonb_set(v_result, '{created}',
- to_jsonb((v_result->>'created')::INTEGER + 1));
- END IF;
-
- ELSE
- v_result := jsonb_set(
- v_result, '{errors}',
- v_result->'errors' || jsonb_build_array(
- jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
- )
- );
- END IF;
-
- EXCEPTION WHEN OTHERS THEN
- v_result := jsonb_set(
- v_result, '{errors}',
- v_result->'errors' || jsonb_build_array(
- jsonb_build_object('row', v_entry, 'error', SQLERRM)
- )
- );
- END;
- END LOOP;
-
- -- ── Emit contact_imported event ──────────────────────────────────────────
- PERFORM record_operational_event(
- p_brand_id,
- 'contact_imported',
- NULL,
- NULL,
- 'admin',
- NULL,
- 'system',
- jsonb_build_object(
- 'created', (v_result->>'created')::INTEGER,
- 'updated', (v_result->>'updated')::INTEGER,
- 'skipped', (v_result->>'skipped')::INTEGER,
- 'total',
- (v_result->>'created')::INTEGER
- + (v_result->>'updated')::INTEGER
- + (v_result->>'skipped')::INTEGER
- )
- );
-
- RETURN v_result;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 8. send_campaign — with campaign_sent emitter
--- (explicit replace; same body as migration 017)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
-RETURNS jsonb
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_campaign communication_campaigns;
- v_settings communication_settings;
- v_audience JSONB;
- v_entry JSONB;
- v_entries JSONB := '[]'::JSONB;
- v_count INTEGER := 0;
-BEGIN
- SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
- END IF;
-
- SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
- END IF;
-
- v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
-
- FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
- LOOP
- CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
-
- v_entries := v_entries || jsonb_build_array(jsonb_build_object(
- 'brand_id', v_campaign.brand_id,
- 'campaign_id', p_campaign_id,
- 'customer_id', nullif(v_entry->>'id', ''),
- 'customer_email', v_entry->>'email',
- 'delivery_method','email',
- 'subject', v_campaign.subject,
- 'body_preview', left(v_campaign.body_text, 500),
- 'status', 'queued'
- ));
- v_count := v_count + 1;
- END LOOP;
-
- PERFORM log_communication_messages(v_entries);
-
- UPDATE communication_campaigns
- SET status = 'sent', sent_at = now(), updated_at = now()
- WHERE id = p_campaign_id;
-
- -- ── Emit campaign_sent event ────────────────────────────────────────────
- PERFORM record_operational_event(
- v_campaign.brand_id,
- 'campaign_sent',
- 'campaign',
- p_campaign_id,
- 'system',
- NULL,
- 'system',
- jsonb_build_object(
- 'messages_logged', v_count,
- 'subject', v_campaign.subject
- )
- );
-
- RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
-END;
-$$;
--- ───────────────────────────────────────────
--- 023_cart_availability_check.sql
--- ───────────────────────────────────────────
--- Migration 023: Fix cart availability check
--- Replaces unreliable client-side product_stops query with a
--- SECURITY DEFINER RPC that bypasses RLS and returns structured availability.
---
--- The cart page's availability check was:
--- 1. Unreliable — anon/frontend query may be blocked by RLS or return empty
--- 2. Conflating "no rows" with "product is unavailable"
--- 3. Blocking all stops even when the query itself failed
---
--- This adds: check_stop_product_availability(p_stop_id, p_product_ids)
--- Returns: { product_id, is_available }[] for each requested product.
--- The cart page uses this to show truly incompatible items separately
--- from query errors.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. check_stop_product_availability RPC
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.check_stop_product_availability(
- p_stop_id UUID,
- p_product_ids UUID[]
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_result JSONB := '[]'::JSONB;
- v_pid UUID;
-BEGIN
- FOR v_pid IN SELECT unnest(p_product_ids)
- LOOP
- v_result := v_result || jsonb_build_array(jsonb_build_object(
- 'product_id', v_pid,
- 'is_available', EXISTS(
- SELECT 1 FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = v_pid
- )
- ));
- END LOOP;
-
- RETURN v_result;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. Update cart/page.tsx to use the RPC + show query errors distinctly
--- (done in the application code, not the migration)
--- ═══════════════════════════════════════════════════════════════════════════
---
--- Changes to src/app/cart/page.tsx:
--- - handleStopSelect: POST to check_stop_product_availability RPC instead of
--- direct product_stops query. Handle errors distinctly from unavailability.
--- - Add availabilityError state — if RPC fails, show "Unable to verify
--- availability" but allow checkout to proceed (server will catch true errors).
--- - Add per-product availabilityError flag to distinguish query failure
--- from confirmed unavailability.
--- ───────────────────────────────────────────
--- 024_stop_product_assignment_rpcs.sql
--- ───────────────────────────────────────────
--- Migration 024: Stop-product assignment via SECURITY DEFINER RPCs
--- Replaces direct INSERT/DELETE on product_stops (blocked by RLS)
--- with admin-authorized RPCs that validate brand alignment.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop
--- Idempotent: if row already exists, returns the existing row.
--- Validates: stop exists, product exists, brands match.
--- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_result JSONB;
-BEGIN
- -- Verify stop exists and get its brand
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product exists and get its brand
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Brand alignment check
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
- );
- END IF;
-
- -- Authorization: must be platform_admin or brand_admin for this brand
- IF NOT EXISTS (
- SELECT 1 FROM admin_users
- WHERE user_id = auth.uid()
- AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
- ) THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized to assign products to this stop');
- END IF;
-
- -- Idempotent insert: check if row already exists
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- -- Insert new row
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop
--- Deletes the product_stop row. Safe — no side effects if row doesn't exist.
--- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
-BEGIN
- -- Verify stop exists and get its brand
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Authorization: must be platform_admin or brand_admin for this brand
- IF NOT EXISTS (
- SELECT 1 FROM admin_users
- WHERE user_id = auth.uid()
- AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
- ) THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized to unassign products from this stop');
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. get_stop_products
--- Returns all products assigned to a stop, with product details.
--- Used by admin UI to refresh the list after assign/unassign.
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN jsonb_build_object('products', (
- SELECT COALESCE(jsonb_agg(
- jsonb_build_object(
- 'id', ps.id,
- 'product_id', ps.product_id,
- 'name', p.name,
- 'type', p.type,
- 'price', p.price
- )
- ), '[]'::JSONB)
- FROM product_stops ps
- JOIN products p ON p.id = ps.product_id
- WHERE ps.stop_id = p_stop_id
- ));
-END;
-$$;
--- ───────────────────────────────────────────
--- 025_fix_assignment_rpc_auth.sql
--- ───────────────────────────────────────────
--- Migration 025: Fix admin assignment RPC authorization
---
--- Problem: auth.uid() is NULL when RPC is called via REST (anon key).
--- SECURITY DEFINER runs as postgres, but auth.uid() reflects the session user.
--- A direct REST call has no authenticated session → auth.uid() = NULL.
---
--- Fix: accept p_caller_uid as an explicit parameter from the admin UI.
--- The UI already knows the current user from getAdminUser().
--- The RPC validates authorization by looking up admin_users with p_caller_uid.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop (corrected auth)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid UUID -- explicitly passed by the admin UI
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_result JSONB;
-BEGIN
- -- Verify stop exists and get its brand
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product exists and get its brand
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Brand alignment check
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
- );
- END IF;
-
- -- Look up the admin user with the explicitly-passed caller UID
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
- END IF;
-
- -- Authorization: platform_admin can manage any stop; brand_admin only their brand
- IF v_admin_role = 'platform_admin' THEN
- -- platform_admin: allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- brand_admin for this brand: allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized to assign products to this stop — requires platform_admin or brand_admin for this brand'
- );
- END IF;
-
- -- Idempotent: if already assigned, return existing row
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- -- Insert new row
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop (corrected auth)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
-BEGIN
- -- Verify stop exists and get its brand
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Look up admin user
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
- END IF;
-
- -- Authorization
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized to unassign products from this stop'
- );
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. get_stop_products — no auth needed for product list (reads data only)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN jsonb_build_object('products', (
- SELECT COALESCE(jsonb_agg(
- jsonb_build_object(
- 'id', ps.id,
- 'product_id', ps.product_id,
- 'name', p.name,
- 'type', p.type,
- 'price', p.price
- )
- ), '[]'::JSONB)
- FROM product_stops ps
- JOIN products p ON p.id = ps.product_id
- WHERE ps.stop_id = p_stop_id
- ));
-END;
-$$;
--- ───────────────────────────────────────────
--- 026_debug_assignment_rpc.sql
--- ───────────────────────────────────────────
--- Migration 026: Debug stop-product assignment
--- Temporary diagnostic RPC to reveal exactly why assignment authorization fails.
--- Remove this after the bug is fixed.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- debug_stop_product_assignment
--- Returns a detailed diagnostics object so we can see which check failed.
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_admin_found BOOLEAN := false;
- v_stop_found BOOLEAN := false;
- v_product_found BOOLEAN := false;
- v_brand_match BOOLEAN;
- v_authorized BOOLEAN := false;
- v_reason TEXT := 'not checked';
-BEGIN
- -- Check stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF FOUND THEN
- v_stop_found := true;
- ELSE
- v_reason := 'stop not found';
- END IF;
-
- -- Check product
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF FOUND THEN
- v_product_found := true;
- ELSE
- v_reason := 'product not found';
- END IF;
-
- -- Check admin_users
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid;
-
- IF FOUND THEN
- v_admin_found := true;
- ELSE
- v_reason := 'admin user not found in admin_users table';
- END IF;
-
- -- Brand match
- IF v_stop_found AND v_product_found THEN
- v_brand_match := (v_stop_brand_id = v_product_brand_id);
- IF NOT v_brand_match THEN
- v_reason := 'brand mismatch between stop and product';
- END IF;
- END IF;
-
- -- Authorization decision
- IF v_admin_found AND v_stop_found AND v_product_found AND v_brand_match THEN
- IF v_admin_role = 'platform_admin' THEN
- v_authorized := true;
- v_reason := 'authorized as platform_admin';
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- v_authorized := true;
- v_reason := 'authorized as brand_admin for this brand';
- ELSE
- v_authorized := false;
- v_reason := 'admin role "' || v_admin_role || '" with brand_id "' ||
- COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
- '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
- END IF;
- END IF;
-
- RETURN jsonb_build_object(
- 'caller_uid', p_caller_uid,
- 'admin_found', v_admin_found,
- 'admin_role', v_admin_role,
- 'admin_brand_id', v_admin_brand_id,
- 'stop_found', v_stop_found,
- 'stop_brand_id', v_stop_brand_id,
- 'product_found', v_product_found,
- 'product_brand_id', v_product_brand_id,
- 'brand_match', v_brand_match,
- 'authorized', v_authorized,
- 'reason', v_reason
- );
-END;
-$$;
--- ───────────────────────────────────────────
--- 027_fix_rpc_signature_conflict.sql
--- ───────────────────────────────────────────
--- Migration 027: Fix assignment RPC signature conflict
---
--- Root cause: Migration 024 created assign_product_to_stop(p_stop_id, p_product_id)
--- with 2 params. Migration 025 tried to CREATE OR REPLACE it with 3 params,
--- but CREATE OR REPLACE cannot change parameter count — the replacement failed
--- and the stale 2-param version remains in the schema.
---
--- Frontend calls with 3 params (p_caller_uid), but PostgreSQL matches the
--- 2-param overload and returns "function does not exist" (400) because the
--- 3-param call has no match.
---
--- Fix:
--- 1. DROP the old 2-param overloads explicitly
--- 2. CREATE the 3-param versions cleanly
--- 3. Notify PostgREST schema cache to reload
--- ═══════════════════════════════════════════════════════════════════════════
-
--- Drop stale 2-param versions (migrations 024)
-DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
-DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop (3-param, SECURITY DEFINER)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_result JSONB;
-BEGIN
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Cross-brand check
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand'
- );
- END IF;
-
- -- Look up caller in admin_users
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization: platform_admin OR brand_admin for this brand
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
- );
- END IF;
-
- -- Idempotent insert
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop (3-param, SECURITY DEFINER)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid UUID
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
-BEGIN
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Look up caller
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. get_stop_products (no auth, product list)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN jsonb_build_object('products', (
- SELECT COALESCE(jsonb_agg(
- jsonb_build_object('id', ps.id, 'product_id', ps.product_id,
- 'name', p.name, 'type', p.type, 'price', p.price)
- ), '[]'::JSONB)
- FROM product_stops ps
- JOIN products p ON p.id = ps.product_id
- WHERE ps.stop_id = p_stop_id
- ));
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. Refresh PostgREST schema cache so it picks up the new signatures
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 028_fix_caller_uid_type.sql
--- ───────────────────────────────────────────
--- Migration 028: Fix dev-user UUID mismatch and clean up RPC signatures
---
--- Root cause: p_caller_uid was defined as UUID, but the dev session
--- (getAdminUser) returns user_id = 'dev-user-00000000-...' which is not
--- a valid UUID. PostgreSQL rejects it with "invalid input syntax for type uuid".
---
--- Fix: change p_caller_uid to TEXT — comparison with admin_users.user_id
--- (UUID column) works fine since PostgreSQL casts TEXT to UUID implicitly.
---
--- Also cleans up: drops stale 2-param overloads so PostgREST has no ambiguity.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Drop stale 2-param overloads from migrations 024/025
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
-DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop (p_caller_uid is TEXT to accept dev-user format)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT -- TEXT to accept both UUIDs and "dev-user-..." strings
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_result JSONB;
-BEGIN
- -- Verify stop exists
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product exists
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Cross-brand guard
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
- );
- END IF;
-
- -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization: platform_admin OR brand_admin for this brand
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
- );
- END IF;
-
- -- Idempotent insert
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop (p_caller_uid is TEXT)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
-BEGIN
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Look up admin
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. debug_stop_product_assignment (p_caller_uid is TEXT)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_admin_found BOOLEAN := false;
- v_stop_found BOOLEAN := false;
- v_product_found BOOLEAN := false;
- v_brand_match BOOLEAN;
- v_authorized BOOLEAN := false;
- v_reason TEXT := 'not checked';
-BEGIN
- -- Check stop
- SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
- IF FOUND THEN v_stop_found := true; END IF;
-
- -- Check product
- SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
- IF FOUND THEN v_product_found := true; END IF;
-
- -- Check admin_users
- BEGIN
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID;
- IF FOUND THEN v_admin_found := true; END IF;
- EXCEPTION WHEN OTHERS THEN
- v_reason := 'admin lookup failed: ' || SQLERRM;
- END;
-
- -- Brand match
- IF v_stop_found AND v_product_found THEN
- v_brand_match := (v_stop_brand_id = v_product_brand_id);
- END IF;
-
- -- Authorization decision
- IF v_admin_found AND v_stop_found AND v_product_found THEN
- IF v_brand_match THEN
- IF v_admin_role = 'platform_admin' THEN
- v_authorized := true; v_reason := 'authorized as platform_admin';
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
- ELSE
- v_authorized := false;
- v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
- COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
- '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
- END IF;
- ELSE
- v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
- ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
- END IF;
- END IF;
-
- RETURN jsonb_build_object(
- 'caller_uid', p_caller_uid,
- 'admin_found', v_admin_found,
- 'admin_role', v_admin_role,
- 'admin_brand_id', v_admin_brand_id,
- 'stop_found', v_stop_found,
- 'stop_brand_id', v_stop_brand_id,
- 'product_found', v_product_found,
- 'product_brand_id', v_product_brand_id,
- 'brand_match', v_brand_match,
- 'authorized', v_authorized,
- 'reason', v_reason
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 029_drop_stale_overloads.sql
--- ───────────────────────────────────────────
--- Migration 029: Remove all stale overloads, keep only TEXT caller_uid versions
---
--- Root cause: Migration 028 changed p_caller_uid to TEXT in the CREATE OR REPLACE
--- body, but the 3-param UUID overload (from migrations 024/025/027) was never
--- dropped. PostgreSQL now has TWO valid candidates:
--- assign_product_to_stop(uuid, uuid, text) -- migration 028
--- assign_product_to_stop(uuid, uuid, uuid) -- migration 027
--- PostgREST cannot resolve which to call → "Could not choose the best candidate".
---
--- Fix: DROP all old signatures explicitly, then CREATE ONLY the TEXT versions.
--- ═══════════════════════════════════════════════════════════════════════════
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Drop ALL stale overloads for each function
--- ═══════════════════════════════════════════════════════════════════════════
-
--- assign_product_to_stop
-DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
-DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID, UUID);
-
--- unassign_product_from_stop
-DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
-DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID, UUID);
-
--- debug_stop_product_assignment
-DROP FUNCTION IF EXISTS public.debug_stop_product_assignment(UUID, UUID, UUID);
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop (TEXT caller_uid)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_result JSONB;
-BEGIN
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Cross-brand guard
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
- );
- END IF;
-
- -- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization: platform_admin OR brand_admin for this brand
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
- );
- END IF;
-
- -- Idempotent insert
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop (TEXT caller_uid)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
-BEGIN
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Look up admin
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. debug_stop_product_assignment (TEXT caller_uid)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_admin_found BOOLEAN := false;
- v_stop_found BOOLEAN := false;
- v_product_found BOOLEAN := false;
- v_brand_match BOOLEAN;
- v_authorized BOOLEAN := false;
- v_reason TEXT := 'not checked';
-BEGIN
- -- Check stop
- SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
- IF FOUND THEN v_stop_found := true; END IF;
-
- -- Check product
- SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
- IF FOUND THEN v_product_found := true; END IF;
-
- -- Check admin_users
- BEGIN
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = p_caller_uid::UUID;
- IF FOUND THEN v_admin_found := true; END IF;
- EXCEPTION WHEN OTHERS THEN
- v_reason := 'admin lookup failed: ' || SQLERRM;
- END;
-
- -- Brand match
- IF v_stop_found AND v_product_found THEN
- v_brand_match := (v_stop_brand_id = v_product_brand_id);
- END IF;
-
- -- Authorization decision
- IF v_admin_found AND v_stop_found AND v_product_found THEN
- IF v_brand_match THEN
- IF v_admin_role = 'platform_admin' THEN
- v_authorized := true; v_reason := 'authorized as platform_admin';
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
- ELSE
- v_authorized := false;
- v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
- COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
- '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
- END IF;
- ELSE
- v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
- ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
- END IF;
- END IF;
-
- RETURN jsonb_build_object(
- 'caller_uid', p_caller_uid,
- 'admin_found', v_admin_found,
- 'admin_role', v_admin_role,
- 'admin_brand_id', v_admin_brand_id,
- 'stop_found', v_stop_found,
- 'stop_brand_id', v_stop_brand_id,
- 'product_found', v_product_found,
- 'product_brand_id', v_product_brand_id,
- 'brand_match', v_brand_match,
- 'authorized', v_authorized,
- 'reason', v_reason
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Verify: confirm only TEXT signatures remain
--- ═══════════════════════════════════════════════════════════════════════════
-
-SELECT proname, oidvectortypes(proargtypes) AS arg_types
-FROM pg_proc
-WHERE proname IN ('assign_product_to_stop', 'unassign_product_from_stop', 'debug_stop_product_assignment')
- AND pronamespace = 'public'::regnamespace;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 030_normalize_dev_user_caller_uid.sql
--- ───────────────────────────────────────────
--- Migration 030: Normalize dev-user prefix before UUID cast in admin lookup
---
--- Root cause: p_caller_uid = 'dev-user-00000000-...' is not a valid UUID string.
--- p_caller_uid::UUID throws "invalid input syntax for type uuid" before the
--- admin_users lookup can run. The lookup fails even for valid admin UUIDs because
--- the cast throws first.
---
--- Fix: normalize p_caller_uid before casting:
--- - If it starts with 'dev-user-', strip that prefix then cast to UUID
--- - Otherwise cast directly
--- This lets the existing NOT FOUND handling catch the dev-user case gracefully.
---
--- Also adds exception handling around the cast so invalid strings don't crash
--- the function — the IF NOT FOUND path handles it.
---
--- Applies to: assign_product_to_stop, unassign_product_from_stop, debug_stop_product_assignment
--- ═══════════════════════════════════════════════════════════════════════════
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. assign_product_to_stop
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_existing UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_result JSONB;
- v_lookup_uid UUID;
-BEGIN
- -- Normalize: strip 'dev-user-' prefix before UUID cast
- IF p_caller_uid LIKE 'dev-user-%' THEN
- v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
- ELSE
- v_lookup_uid := p_caller_uid::UUID;
- END IF;
-
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Verify product
- SELECT brand_id INTO v_product_brand_id
- FROM products
- WHERE id = p_product_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Product not found');
- END IF;
-
- -- Cross-brand guard
- IF v_stop_brand_id != v_product_brand_id THEN
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
- );
- END IF;
-
- -- Look up admin by normalized user_id
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = v_lookup_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization: platform_admin OR brand_admin for this brand
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object(
- 'success', false,
- 'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
- );
- END IF;
-
- -- Idempotent insert
- SELECT id INTO v_existing
- FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- IF v_existing IS NOT NULL THEN
- RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
- END IF;
-
- INSERT INTO product_stops (stop_id, product_id)
- VALUES (p_stop_id, p_product_id)
- RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
- INTO v_result;
-
- RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. unassign_product_from_stop
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_lookup_uid UUID;
-BEGIN
- -- Normalize: strip 'dev-user-' prefix before UUID cast
- IF p_caller_uid LIKE 'dev-user-%' THEN
- v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
- ELSE
- v_lookup_uid := p_caller_uid::UUID;
- END IF;
-
- -- Verify stop
- SELECT brand_id INTO v_stop_brand_id
- FROM stops
- WHERE id = p_stop_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
- END IF;
-
- -- Look up admin
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = v_lookup_uid
- LIMIT 1;
-
- IF NOT FOUND OR v_admin_role IS NULL THEN
- RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
- END IF;
-
- -- Authorization
- IF v_admin_role = 'platform_admin' THEN
- -- allowed
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- -- allowed
- ELSE
- RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
- END IF;
-
- DELETE FROM product_stops
- WHERE stop_id = p_stop_id AND product_id = p_product_id;
-
- RETURN jsonb_build_object('success', true, 'deleted', true);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. debug_stop_product_assignment
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
- p_stop_id UUID,
- p_product_id UUID,
- p_caller_uid TEXT
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_stop_brand_id UUID;
- v_product_brand_id UUID;
- v_admin_role TEXT;
- v_admin_brand_id UUID;
- v_admin_found BOOLEAN := false;
- v_stop_found BOOLEAN := false;
- v_product_found BOOLEAN := false;
- v_brand_match BOOLEAN;
- v_authorized BOOLEAN := false;
- v_reason TEXT := 'not checked';
- v_lookup_uid UUID;
-BEGIN
- -- Normalize: strip 'dev-user-' prefix before UUID cast
- IF p_caller_uid LIKE 'dev-user-%' THEN
- v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
- ELSE
- v_lookup_uid := p_caller_uid::UUID;
- END IF;
-
- -- Check stop
- SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
- IF FOUND THEN v_stop_found := true; END IF;
-
- -- Check product
- SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
- IF FOUND THEN v_product_found := true; END IF;
-
- -- Check admin_users
- BEGIN
- SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
- FROM admin_users
- WHERE user_id = v_lookup_uid;
- IF FOUND THEN v_admin_found := true; END IF;
- EXCEPTION WHEN OTHERS THEN
- v_reason := 'admin lookup failed: ' || SQLERRM;
- END;
-
- -- Brand match
- IF v_stop_found AND v_product_found THEN
- v_brand_match := (v_stop_brand_id = v_product_brand_id);
- END IF;
-
- -- Authorization decision
- IF v_admin_found AND v_stop_found AND v_product_found THEN
- IF v_brand_match THEN
- IF v_admin_role = 'platform_admin' THEN
- v_authorized := true; v_reason := 'authorized as platform_admin';
- ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
- v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
- ELSE
- v_authorized := false;
- v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
- COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
- '" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
- END IF;
- ELSE
- v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
- ', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
- END IF;
- END IF;
-
- RETURN jsonb_build_object(
- 'caller_uid', p_caller_uid,
- 'lookup_uid', v_lookup_uid::TEXT,
- 'admin_found', v_admin_found,
- 'admin_role', v_admin_role,
- 'admin_brand_id', v_admin_brand_id,
- 'stop_found', v_stop_found,
- 'stop_brand_id', v_stop_brand_id,
- 'product_found', v_product_found,
- 'product_brand_id', v_product_brand_id,
- 'brand_match', v_brand_match,
- 'authorized', v_authorized,
- 'reason', v_reason
- );
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 031_reports_v1_rpcs.sql
--- ───────────────────────────────────────────
--- Migration 031: Reports V1 — Operational Reporting RPCs
--- SECURITY DEFINER — bypasses RLS, enforces brand scoping in SQL
--- All functions accept p_brand_id NULL = platform_admin sees all brands
--- brand_admin is filtered at the page level via getAdminUser()
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. get_reports_summary — 10 KPI cards
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_reports_summary(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_result JSONB;
-BEGIN
- -- Base WHERE: always filter by brand if provided, always filter by date
- -- Status filter: exclude canceled orders from all revenue/order counts
-
- WITH date_orders AS (
- SELECT o.id, o.subtotal, o.status, o.brand_id,
- CASE WHEN EXISTS (
- SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup'
- ) THEN true ELSE false END AS has_pickup,
- CASE WHEN EXISTS (
- SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
- ) THEN true ELSE false END AS has_shipping
- FROM orders o
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND o.status != 'canceled'
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
- ),
- pickup_orders AS (
- SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_pickup
- ),
- shipping_orders AS (
- SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_shipping
- ),
- pending_pickups AS (
- SELECT COUNT(DISTINCT o.id) AS cnt
- FROM date_orders o
- WHERE o.has_pickup AND o.status = 'pending'
- ),
- completed_pickups AS (
- SELECT COUNT(DISTINCT o.id) AS cnt
- FROM date_orders o
- WHERE o.has_pickup AND o.status = 'completed'
- ),
- contacts_added AS (
- SELECT COUNT(*) AS cnt
- FROM communication_contacts cc
- WHERE cc.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND cc.source = 'import'
- AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
- ),
- campaigns_sent AS (
- SELECT COUNT(*) AS cnt
- FROM communication_campaigns cc
- WHERE cc.sent_at::DATE BETWEEN p_start_date AND p_end_date
- AND cc.status = 'sent'
- AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
- ),
- messages_logged AS (
- SELECT COUNT(*) AS cnt
- FROM communication_message_logs ml
- WHERE ml.sent_at::DATE BETWEEN p_start_date AND p_end_date
- AND (p_brand_id IS NULL OR ml.brand_id = p_brand_id)
- )
- SELECT jsonb_build_object(
- 'gross_sales', COALESCE(SUM(subtotal), 0),
- 'total_orders', COUNT(DISTINCT id),
- 'avg_order_value', CASE WHEN COUNT(id) > 0 THEN ROUND(AVG(subtotal), 2) ELSE 0 END,
- 'pickup_orders', COALESCE((SELECT cnt FROM pickup_orders), 0),
- 'shipping_orders', COALESCE((SELECT cnt FROM shipping_orders), 0),
- 'pending_pickups', COALESCE((SELECT cnt FROM pending_pickups), 0),
- 'completed_pickups', COALESCE((SELECT cnt FROM completed_pickups), 0),
- 'contacts_added', COALESCE((SELECT cnt FROM contacts_added), 0),
- 'campaigns_sent', COALESCE((SELECT cnt FROM campaigns_sent), 0),
- 'messages_logged', COALESCE((SELECT cnt FROM messages_logged), 0)
- ) INTO v_result
- FROM date_orders;
-
- RETURN v_result;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. get_orders_by_stop_report — orders grouped by stop
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_orders_by_stop_report(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN COALESCE(jsonb_agg(
- jsonb_build_object(
- 'stop_name', r.stop_name,
- 'city', r.city,
- 'state', r.state,
- 'date', r.date,
- 'order_count', r.order_count,
- 'gross_sales', r.gross_sales,
- 'pending_count', r.pending_count,
- 'completed_count', r.completed_count
- ) ORDER BY r.date DESC
- ), '[]'::JSONB)
- FROM (
- SELECT
- s.city || ', ' || s.state AS stop_name,
- s.city,
- s.state,
- s.date,
- COUNT(DISTINCT o.id) AS order_count,
- COALESCE(SUM(o.subtotal), 0) AS gross_sales,
- COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending_count,
- COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed_count
- FROM orders o
- JOIN stops s ON s.id = o.stop_id
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND o.status != 'canceled'
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
- GROUP BY s.id, s.city, s.state, s.date
- ) r;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. get_sales_by_product_report — product revenue
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_sales_by_product_report(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN COALESCE(jsonb_agg(
- jsonb_build_object(
- 'product_name', r.product_name,
- 'units_sold', r.units_sold,
- 'gross_revenue', r.gross_revenue,
- 'avg_price', r.avg_price
- ) ORDER BY r.gross_revenue DESC
- ), '[]'::JSONB)
- FROM (
- SELECT
- p.name AS product_name,
- SUM(oi.quantity) AS units_sold,
- SUM(oi.price * oi.quantity) AS gross_revenue,
- ROUND(AVG(oi.price), 2) AS avg_price
- FROM order_items oi
- JOIN orders o ON o.id = oi.order_id
- JOIN products p ON p.id = oi.product_id
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND o.status != 'canceled'
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
- GROUP BY p.id, p.name
- ) r;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. get_fulfillment_report — pickup / shipping / mixed breakdown
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_fulfillment_report(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_total NUMERIC;
-BEGIN
- SELECT COALESCE(SUM(o.subtotal), 0) INTO v_total
- FROM orders o
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND o.status != 'canceled'
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id);
-
- RETURN COALESCE(jsonb_agg(sub ORDER BY revenue DESC), '[]'::JSONB)
- FROM (
- SELECT
- CASE
- WHEN has_pickup AND has_shipping THEN 'mixed'
- WHEN has_pickup THEN 'pickup'
- ELSE 'shipping'
- END AS fulfillment_type,
- COUNT(DISTINCT o.id) AS order_count,
- SUM(o.subtotal) AS revenue,
- CASE WHEN v_total > 0 THEN ROUND(100.0 * SUM(o.subtotal) / v_total, 1) ELSE 0 END AS pct_of_total
- FROM (
- SELECT o.id, o.subtotal,
- EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') AS has_pickup,
- EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping') AS has_shipping
- FROM orders o
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND o.status != 'canceled'
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
- ) o
- GROUP BY fulfillment_type
- ) sub;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 5. get_pickup_status_by_stop — per-stop pickup status
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_pickup_status_by_stop(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN COALESCE(jsonb_agg(
- jsonb_build_object(
- 'stop_name', r.stop_name,
- 'city', r.city,
- 'date', r.date,
- 'total_orders', r.total_orders,
- 'pending', r.pending,
- 'completed', r.completed,
- 'canceled', r.canceled
- ) ORDER BY r.date DESC
- ), '[]'::JSONB)
- FROM (
- SELECT
- s.city || ', ' || s.state AS stop_name,
- s.city,
- s.date,
- COUNT(DISTINCT o.id) AS total_orders,
- COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending,
- COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed,
- COUNT(DISTINCT CASE WHEN o.status = 'canceled' THEN o.id END) AS canceled
- FROM orders o
- JOIN stops s ON s.id = o.stop_id
- WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup')
- AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
- GROUP BY s.id, s.city, s.state, s.date
- ) r;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 6. get_contact_growth_report — new contacts over time
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_contact_growth_report(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN COALESCE(jsonb_agg(
- jsonb_build_object(
- 'date', r.date,
- 'new_contacts', r.new_contacts,
- 'imports', r.imports,
- 'total', r.total
- ) ORDER BY r.date DESC
- ), '[]'::JSONB)
- FROM (
- SELECT
- d::DATE AS date,
- COALESCE(SUM(CASE WHEN cc.source IS DISTINCT FROM 'import' THEN 1 ELSE 0 END), 0) AS new_contacts,
- COALESCE(SUM(CASE WHEN cc.source = 'import' THEN 1 ELSE 0 END), 0) AS imports,
- COUNT(cc.*) AS total
- FROM generate_series(p_start_date, p_end_date, '1 day'::INTERVAL) d
- LEFT JOIN communication_contacts cc
- ON cc.created_at::DATE = d::DATE
- AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
- GROUP BY d::DATE
- ) r;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 7. get_campaign_activity_report — campaign status and message counts
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION public.get_campaign_activity_report(
- p_brand_id UUID,
- p_start_date DATE,
- p_end_date DATE
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN COALESCE(jsonb_agg(
- jsonb_build_object(
- 'campaign_name', c.name,
- 'status', c.status,
- 'campaign_type', c.campaign_type,
- 'sent_at', c.sent_at,
- 'messages_logged', COALESCE(ml.sent_count, 0)
- ) ORDER BY c.sent_at DESC NULLS LAST
- ), '[]'::JSONB)
- FROM communication_campaigns c
- LEFT JOIN (
- SELECT campaign_id, COUNT(*) AS sent_count
- FROM communication_message_logs
- WHERE sent_at::DATE BETWEEN p_start_date AND p_end_date
- GROUP BY campaign_id
- ) ml ON ml.campaign_id = c.id
- WHERE c.created_at::DATE BETWEEN p_start_date AND p_end_date
- AND (p_brand_id IS NULL OR c.brand_id = p_brand_id);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 032_store_employee_role.sql
--- ───────────────────────────────────────────
--- Migration 032: Store Employee Role
---
--- Add store_employee to the platform's role model. No new columns —
--- admin_users.role already exists with values 'platform_admin', 'brand_admin'.
--- store_employee just adds a third recognized role.
---
--- RLS: store_employee can read orders for their brand (brand_id scoped).
--- SQL RPCs: get_admin_orders + get_admin_order_detail add store_employee brand scoping.
--- No changes to product/stop/communications/settings RLS — store_employee shouldn't
--- be granted those tables in RLS at all (they go through existing SECURITY DEFINER RPCs
--- with explicit can_manage_* guard checks in TypeScript actions).
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. RLS policy: store_employee can read their brand's orders
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP POLICY IF EXISTS "Store employee can read their brand orders" ON orders;
-CREATE POLICY "Store employee can read their brand orders"
- ON orders FOR SELECT TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'store_employee'
- AND admin_users.brand_id = (
- SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
- )
- )
- );
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. get_admin_orders — add store_employee brand scoping
--- (SECURITY DEFINER, no RLS — add explicit brand_id check for store_employee)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_orders JSONB;
- v_stops JSONB;
- v_effective_brand_id UUID;
-BEGIN
- -- Resolve effective brand: use p_brand_id if non-null,
- -- otherwise fall back to store_employee's own brand
- IF p_brand_id IS NOT NULL THEN
- v_effective_brand_id := p_brand_id;
- ELSE
- -- For store_employee with no p_brand_id, use their own brand_id
- -- (caller must pass brand_id for store_employee to avoid cross-brand data)
- v_effective_brand_id := NULL;
- END IF;
-
- IF v_effective_brand_id IS NULL THEN
- -- platform_admin or no brand scoping — return all orders
- SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
- INTO v_orders
- FROM (
- SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
- o.stop_id, o.status, o.subtotal, o.pickup_complete,
- o.pickup_completed_at, o.pickup_completed_by, o.created_at,
- CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
- 'id', s.id, 'city', s.city, 'state', s.state,
- 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
- ) END as stops
- FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
- WHERE o.stop_id IS NOT NULL
- LIMIT 500
- ) t;
-
- SELECT COALESCE(jsonb_agg(jsonb_build_object(
- 'id', id, 'city', city, 'state', state,
- 'date', date, 'time', time, 'location', location, 'brand_id', brand_id
- ) ORDER BY date), '[]'::JSONB)
- INTO v_stops FROM stops WHERE active = true;
- ELSE
- -- brand-scoped query (brand_admin, store_employee, or platform_admin filtering)
- SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
- INTO v_orders
- FROM (
- SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
- o.stop_id, o.status, o.subtotal, o.pickup_complete,
- o.pickup_completed_at, o.pickup_completed_by, o.created_at,
- jsonb_build_object(
- 'id', s.id, 'city', s.city, 'state', s.state,
- 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
- ) as stops
- FROM orders o JOIN stops s ON o.stop_id = s.id
- WHERE s.brand_id = v_effective_brand_id
- LIMIT 500
- ) t;
-
- SELECT COALESCE(jsonb_agg(jsonb_build_object(
- 'id', id, 'city', city, 'state', state,
- 'date', date, 'time', time, 'location', location, 'brand_id', brand_id
- ) ORDER BY date), '[]'::JSONB)
- INTO v_stops FROM stops WHERE active = true AND brand_id = v_effective_brand_id;
- END IF;
-
- RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. get_admin_order_detail — brand scoping (already SECURITY DEFINER,
--- add check that store_employee can only view orders in their brand)
--- ═══════════════════════════════════════════════════════════════════════════
-
-CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
-RETURNS JSONB
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_order JSONB;
- v_order_brand_id UUID;
- v_stop_brand_id UUID;
-BEGIN
- -- Resolve order's brand_id (from order.brand_id or stop.brand_id)
- SELECT
- COALESCE(o.brand_id, s.brand_id),
- s.brand_id
- INTO v_order_brand_id, v_stop_brand_id
- FROM orders o
- LEFT JOIN stops s ON o.stop_id = s.id
- WHERE o.id = p_order_id;
-
- -- Brand scoping: if p_brand_id is provided, restrict to that brand
- IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
- RETURN jsonb_build_object('error', 'Order not found or access denied');
- END IF;
-
- SELECT jsonb_build_object(
- 'id', o.id,
- 'customer_name', o.customer_name,
- 'customer_email', o.customer_email,
- 'customer_phone', o.customer_phone,
- 'stop_id', o.stop_id,
- 'status', o.status,
- 'subtotal', o.subtotal,
- 'pickup_complete', o.pickup_complete,
- 'pickup_completed_at', o.pickup_completed_at,
- 'pickup_completed_by', o.pickup_completed_by,
- 'created_at', o.created_at,
- 'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
- 'id', s.id, 'city', s.city, 'state', s.state,
- 'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
- ) END,
- 'order_items', COALESCE((
- SELECT jsonb_agg(jsonb_build_object(
- 'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name,
- 'quantity', oi.quantity, 'price', oi.price, 'fulfillment', oi.fulfillment,
- 'products', jsonb_build_object('name', p.name)
- ))
- FROM order_items oi
- JOIN products p ON oi.product_id = p.id
- WHERE oi.order_id = o.id
- ), '[]'::JSONB)
- )
- INTO v_order
- FROM orders o
- LEFT JOIN stops s ON o.stop_id = s.id
- WHERE o.id = p_order_id;
-
- RETURN v_order;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 033_admin_users_crud.sql
--- ───────────────────────────────────────────
--- Migration 033: Admin Users CRUD
---
--- RPC functions for listing, creating, updating, and deactivating admin_users.
--- Security constraints enforced at SQL level:
--- - platform_admin: can manage all users and any brand
--- - brand_admin with can_manage_users: can only manage users within their brand,
--- cannot create platform_admin, cannot assign foreign brand_id
--- - store_employee: cannot access these functions (guarded at TS route level)
---
--- Display name: joins auth.users raw_user_meta_data -> display_name | name, falls back to email.
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 1. get_admin_users — list users, optionally filtered by brand
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP FUNCTION IF EXISTS get_admin_users(UUID);
-CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL)
-RETURNS TABLE (
- id UUID,
- user_id UUID,
- display_name TEXT,
- email TEXT,
- role TEXT,
- brand_id UUID,
- brand_name TEXT,
- can_manage_products BOOLEAN,
- can_manage_stops BOOLEAN,
- can_manage_orders BOOLEAN,
- can_manage_pickup BOOLEAN,
- can_manage_messages BOOLEAN,
- can_manage_refunds BOOLEAN,
- can_manage_users BOOLEAN,
- can_manage_water_log BOOLEAN,
- can_manage_reports BOOLEAN,
- active BOOLEAN,
- created_at TIMESTAMPTZ,
- last_login TIMESTAMPTZ
-)
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-BEGIN
- RETURN QUERY
- SELECT
- au.id,
- au.user_id,
- COALESCE(
- (au.raw_user_meta_data->>'display_name')::TEXT,
- (au.raw_user_meta_data->>'full_name')::TEXT,
- u.email
- ) AS display_name,
- u.email,
- au.role::TEXT,
- au.brand_id,
- b.name AS brand_name,
- au.can_manage_products,
- au.can_manage_stops,
- au.can_manage_orders,
- au.can_manage_pickup,
- au.can_manage_messages,
- au.can_manage_refunds,
- au.can_manage_users,
- au.can_manage_water_log,
- COALESCE(au.can_manage_reports, false) AS can_manage_reports,
- au.active,
- au.created_at,
- au.last_login
- FROM admin_users au
- JOIN auth.users u ON au.user_id = u.id
- LEFT JOIN brands b ON au.brand_id = b.id
- WHERE
- -- platform_admin sees all; brand_admin sees only their brand
- (
- EXISTS (
- SELECT 1 FROM admin_users caller
- WHERE caller.user_id = auth.uid()
- AND caller.role = 'platform_admin'
- )
- OR
- (
- EXISTS (
- SELECT 1 FROM admin_users caller
- WHERE caller.user_id = auth.uid()
- AND caller.role = 'brand_admin'
- AND caller.can_manage_users = true
- )
- AND (p_brand_id IS NULL OR au.brand_id = p_brand_id)
- AND (
- EXISTS (
- SELECT 1 FROM admin_users caller
- WHERE caller.user_id = auth.uid()
- AND caller.brand_id = au.brand_id
- )
- )
- )
- )
- ORDER BY au.created_at DESC;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 2. create_admin_user — create a new admin user record
---
--- p_email: must already have a Supabase auth account
--- p_role: 'platform_admin' | 'brand_admin' | 'store_employee'
--- p_brand_id: required for brand_admin and store_employee
--- p_flags: JSONB with individual can_manage_* booleans
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP FUNCTION IF EXISTS create_admin_user(TEXT, TEXT, UUID, JSONB);
-CREATE OR REPLACE FUNCTION create_admin_user(
- p_email TEXT,
- p_role TEXT,
- p_brand_id UUID DEFAULT NULL,
- p_flags JSONB DEFAULT '{}'::JSONB
-)
-RETURNS TABLE (
- id UUID,
- user_id UUID,
- display_name TEXT,
- email TEXT,
- role TEXT,
- brand_id UUID,
- active BOOLEAN,
- created_at TIMESTAMPTZ
-)
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_caller_brand_id UUID;
- v_caller_can_manage_users BOOLEAN;
- v_target_user_id UUID;
- v_new_id UUID;
-BEGIN
- -- Caller must be authenticated
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- -- Look up caller
- SELECT role, brand_id, can_manage_users
- INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
- FROM admin_users
- WHERE user_id = auth.uid();
-
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- -- Security: brand_admin cannot create platform_admin
- IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
-
- -- Security: brand_admin can only create users in their own brand
- IF v_caller_role = 'brand_admin' THEN
- IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
- END IF;
- IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'brand_admin cannot create platform_admin';
- END IF;
- END IF;
-
- -- Find the auth user by email
- BEGIN
- SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
- EXCEPTION WHEN OTHERS THEN
- RAISE EXCEPTION 'Auth user with email % not found', p_email;
- END;
-
- IF v_target_user_id IS NULL THEN
- RAISE EXCEPTION 'Auth user with email % not found', p_email;
- END IF;
-
- -- Check no existing admin_users record for this user
- IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
- RAISE EXCEPTION 'Admin user with email % already exists', p_email;
- END IF;
-
- -- Insert new admin user
- INSERT INTO admin_users (
- user_id, role, brand_id,
- 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, active
- ) VALUES (
- v_target_user_id, p_role, p_brand_id,
- COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
- true
- )
- RETURNING id INTO v_new_id;
-
- RETURN QUERY
- SELECT
- au.id, au.user_id,
- COALESCE(
- (au.raw_user_meta_data->>'display_name')::TEXT,
- (au.raw_user_meta_data->>'full_name')::TEXT,
- u.email
- ) AS display_name,
- u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
- FROM admin_users au
- JOIN auth.users u ON au.user_id = u.id
- WHERE au.id = v_new_id;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 3. update_admin_user — update role, brand, flags, or active status
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN);
-CREATE OR REPLACE FUNCTION update_admin_user(
- p_id UUID,
- p_role TEXT DEFAULT NULL,
- p_brand_id UUID DEFAULT NULL,
- p_flags JSONB DEFAULT NULL,
- p_active BOOLEAN DEFAULT NULL
-)
-RETURNS TABLE (
- id UUID,
- user_id UUID,
- display_name TEXT,
- email TEXT,
- role TEXT,
- brand_id UUID,
- active BOOLEAN,
- updated_at TIMESTAMPTZ
-)
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_caller_brand_id UUID;
- v_caller_can_manage_users BOOLEAN;
- v_target_role TEXT;
- v_target_brand_id UUID;
-BEGIN
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- SELECT role, brand_id, can_manage_users
- INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
- FROM admin_users
- WHERE user_id = auth.uid();
-
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- -- Cannot update your own account's role
- IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
- RAISE EXCEPTION 'Cannot update your own admin account';
- END IF;
-
- -- Look up target
- SELECT role, brand_id INTO v_target_role, v_target_brand_id
- FROM admin_users WHERE id = p_id;
-
- IF v_target_role IS NULL THEN
- RAISE EXCEPTION 'Admin user not found';
- END IF;
-
- -- Security: brand_admin cannot demote/promote platform_admin
- IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
- RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
- END IF;
-
- -- Security: brand_admin cannot give platform_admin role
- IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
-
- -- Security: brand_admin can only affect users in their brand
- IF v_caller_role = 'brand_admin' THEN
- IF v_target_brand_id != v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
- END IF;
- IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
- END IF;
- IF p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
- END IF;
-
- -- Apply updates
- UPDATE admin_users SET
- role = COALESCE(p_role, role),
- brand_id = COALESCE(p_brand_id, brand_id),
- can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
- can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
- can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
- can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
- can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
- can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
- can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
- can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
- can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
- active = COALESCE(p_active, active)
- WHERE id = p_id;
-
- RETURN QUERY
- SELECT
- au.id, au.user_id,
- COALESCE(
- (au.raw_user_meta_data->>'display_name')::TEXT,
- (au.raw_user_meta_data->>'full_name')::TEXT,
- u.email
- ) AS display_name,
- u.email, au.role::TEXT, au.brand_id, au.active, NOW() AS updated_at
- FROM admin_users au
- JOIN auth.users u ON au.user_id = u.id
- WHERE au.id = p_id;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 4. delete_admin_user — remove admin user record
--- Cannot delete your own account.
--- ═══════════════════════════════════════════════════════════════════════════
-
-DROP FUNCTION IF EXISTS delete_admin_user(UUID);
-CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
-RETURNS BOOLEAN
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_target_user_id UUID;
-BEGIN
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- -- Cannot delete self
- SELECT user_id INTO v_target_user_id FROM admin_users WHERE id = p_id;
- IF v_target_user_id = auth.uid() THEN
- RAISE EXCEPTION 'Cannot delete your own admin account';
- END IF;
-
- -- brand_admin can only delete users in their brand
- IF v_caller_role = 'brand_admin' THEN
- IF NOT EXISTS (
- SELECT 1 FROM admin_users
- WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
- ) THEN
- RAISE EXCEPTION 'Insufficient permissions to delete this user';
- END IF;
- END IF;
-
- DELETE FROM admin_users WHERE id = p_id;
- RETURN true;
-END;
-$$;
-
--- ═══════════════════════════════════════════════════════════════════════════
--- 5. Refresh PostgREST schema cache
--- ═══════════════════════════════════════════════════════════════════════════
-
-NOTIFY pgrst, 'reload schema';
--- ───────────────────────────────────────────
--- 034_password_change_flow.sql
--- ───────────────────────────────────────────
--- Migration: 034_password_change_flow
--- Adds must_change_password column to admin_users if not exists
--- Adds phone_number column to admin_users if not exists
--- Creates RPC to clear must_change_password after password update
-
-alter table admin_users add column if not exists must_change_password boolean not null default false;
-alter table admin_users add column if not exists phone_number text;
-
--- RPC: clear must_change_password for the authenticated user
-create or replace function clear_must_change_password()
-returns boolean
-language plpgsql
-security definer set search_path = public
-as $$
-begin
- update admin_users
- set must_change_password = false
- where user_id = auth.uid()
- and must_change_password = true;
-
- return true;
-end;
-$$;
-
--- ───────────────────────────────────────────
--- 035_admin_action_logs.sql
--- ───────────────────────────────────────────
--- Migration: 035_admin_action_logs
--- Dedicated audit trail for admin user management actions
-
-CREATE TABLE IF NOT EXISTS admin_action_logs (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- action_type TEXT NOT NULL, -- 'create' | 'update' | 'delete'
- admin_id UUID, -- auth.uid() of the admin performing the action
- admin_email TEXT,
- affected_user_id UUID, -- the admin_users.id being modified
- brand_id UUID,
- details JSONB DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
--- Index for fast lookups by acting admin
-CREATE INDEX IF NOT EXISTS idx_admin_action_logs_admin_id ON admin_action_logs(admin_id);
--- Index for fast lookups by affected user
-CREATE INDEX IF NOT EXISTS idx_admin_action_logs_affected_user ON admin_action_logs(affected_user_id);
--- Index for time-based queries
-CREATE INDEX IF NOT EXISTS idx_admin_action_logs_created_at ON admin_action_logs(created_at DESC);
-
--- RLS: platform_admin can read all; brand_admin reads only their brand
-ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY;
-
-CREATE POLICY "platform_admin_read_all_admin_action_logs" ON admin_action_logs
- FOR SELECT USING (
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'platform_admin'
- )
- );
-
-CREATE POLICY "brand_admin_read_own_brand_admin_action_logs" ON admin_action_logs
- FOR SELECT USING (
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'brand_admin'
- AND au.brand_id = admin_action_logs.brand_id
- )
- );
-
--- Append-only: any authenticated admin user can insert (enforced at RPC level)
-CREATE POLICY "admin_action_logs_insert" ON admin_action_logs
- FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
-
--- ============================================================
--- log_admin_action — SECURITY DEFINER, bypasses RLS
--- Called by admin user CRUD actions to record what changed.
--- Payload: { action_type, admin_id, admin_email, affected_user_id, brand_id, details }
--- ============================================================
-CREATE OR REPLACE FUNCTION log_admin_action(p_payload JSONB)
-RETURNS UUID
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_log_id UUID;
-BEGIN
- INSERT INTO admin_action_logs (
- action_type, admin_id, admin_email, affected_user_id, brand_id, details
- ) VALUES (
- p_payload->>'action_type',
- CASE WHEN (p_payload->>'admin_id') IS NULL THEN NULL ELSE (p_payload->>'admin_id')::UUID END,
- p_payload->>'admin_email',
- CASE WHEN (p_payload->>'affected_user_id') IS NULL THEN NULL ELSE (p_payload->>'affected_user_id')::UUID END,
- CASE WHEN (p_payload->>'brand_id') IS NULL THEN NULL ELSE (p_payload->>'brand_id')::UUID END,
- COALESCE(p_payload->'details', '{}'::JSONB)
- )
- RETURNING id INTO v_log_id;
-
- RETURN v_log_id;
-END;
-$$;
-
--- ============================================================
--- log_user_activity — SECURITY DEFINER, bypasses RLS
--- Tracks per-user activities: logins, password changes, profile updates.
--- Payload: { user_id, activity_type, details, ip_address, user_agent }
--- ============================================================
-CREATE OR REPLACE FUNCTION log_user_activity(p_payload JSONB)
-RETURNS UUID
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_log_id UUID;
-BEGIN
- INSERT INTO user_activity_logs (
- user_id, activity_type, details, ip_address, user_agent
- ) VALUES (
- CASE WHEN (p_payload->>'user_id') IS NULL THEN NULL ELSE (p_payload->>'user_id')::UUID END,
- p_payload->>'activity_type',
- COALESCE(p_payload->'details', '{}'::JSONB),
- p_payload->>'ip_address',
- p_payload->>'user_agent'
- )
- RETURNING id INTO v_log_id;
-
- RETURN v_log_id;
-END;
-$$;
-
-
--- ───────────────────────────────────────────
--- 036_user_activity_logs.sql
--- ───────────────────────────────────────────
--- Migration: 036_user_activity_logs
--- Tracks per-user activities: logins, password changes, profile updates
-
-CREATE TABLE IF NOT EXISTS user_activity_logs (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL,
- activity_type TEXT NOT NULL, -- 'login' | 'logout' | 'password_change' | 'profile_update' | 'email_change'
- details JSONB DEFAULT '{}',
- ip_address TEXT,
- user_agent TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-);
-
--- Index for fast per-user log lookups
-CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_id ON user_activity_logs(user_id);
--- Index for time-based queries per user
-CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at DESC);
-
--- RLS: user can read own logs; platform_admin can read all
-ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY;
-
-CREATE POLICY "user_read_own_activity_logs" ON user_activity_logs
- FOR SELECT USING (auth.uid() = user_id);
-
-CREATE POLICY "platform_admin_read_all_user_activity_logs" ON user_activity_logs
- FOR SELECT USING (
- EXISTS (
- SELECT 1 FROM admin_users au
- WHERE au.user_id = auth.uid()
- AND au.role = 'platform_admin'
- )
- );
-
--- Any authenticated user can insert their own log entries
-CREATE POLICY "user_insert_own_activity_logs" ON user_activity_logs
- FOR INSERT WITH CHECK (auth.uid() = user_id);
-
--- ───────────────────────────────────────────
--- 037_update_admin_user_profile.sql
--- ───────────────────────────────────────────
--- Migration: 037_update_admin_user_profile
--- Extends update_admin_user to accept display_name and phone_number.
--- Updates admin_users and syncs auth.users raw_user_meta_data.
--- After update, writes to admin_action_logs.
-
-DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN, TEXT, TEXT);
-CREATE OR REPLACE FUNCTION update_admin_user(
- p_id UUID,
- p_role TEXT DEFAULT NULL,
- p_brand_id UUID DEFAULT NULL,
- p_flags JSONB DEFAULT NULL,
- p_active BOOLEAN DEFAULT NULL,
- p_display_name TEXT DEFAULT NULL,
- p_phone_number TEXT DEFAULT NULL
-)
-RETURNS TABLE (
- id UUID,
- user_id UUID,
- display_name TEXT,
- email TEXT,
- role TEXT,
- brand_id UUID,
- active BOOLEAN,
- phone_number TEXT,
- updated_at TIMESTAMPTZ
-)
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_caller_brand_id UUID;
- v_caller_can_manage_users BOOLEAN;
- v_target_role TEXT;
- v_target_brand_id UUID;
- v_target_user_id UUID;
- v_admin_email TEXT;
-BEGIN
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- SELECT role, brand_id, can_manage_users
- INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
- FROM admin_users
- WHERE user_id = auth.uid();
-
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- -- Cannot update your own account's role
- IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
- RAISE EXCEPTION 'Cannot update your own admin account';
- END IF;
-
- -- Look up target
- SELECT role, brand_id, user_id INTO v_target_role, v_target_brand_id, v_target_user_id
- FROM admin_users WHERE id = p_id;
-
- IF v_target_role IS NULL THEN
- RAISE EXCEPTION 'Admin user not found';
- END IF;
-
- -- Security: brand_admin cannot demote/promote platform_admin
- IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
- RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
- END IF;
-
- -- Security: brand_admin cannot give platform_admin role
- IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
-
- -- Security: brand_admin can only affect users in their brand
- IF v_caller_role = 'brand_admin' THEN
- IF v_target_brand_id != v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
- END IF;
- IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
- END IF;
- IF p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
- END IF;
-
- -- Get caller email for audit log
- SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
-
- -- Apply updates to admin_users
- UPDATE admin_users SET
- role = COALESCE(p_role, role),
- brand_id = COALESCE(p_brand_id, brand_id),
- can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
- can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
- can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
- can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
- can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
- can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
- can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
- can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
- can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
- active = COALESCE(p_active, active),
- display_name = COALESCE(p_display_name, display_name),
- phone_number = COALESCE(p_phone_number, phone_number)
- WHERE id = p_id;
-
- -- Sync display_name / phone_number to auth.users raw_user_meta_data
- IF p_display_name IS NOT NULL OR p_phone_number IS NOT NULL THEN
- UPDATE auth.users SET
- raw_user_meta_data = jsonb_strip_nulls(
- jsonb_set(
- COALESCE(raw_user_meta_data, '{}'::jsonb),
- '{display_name}',
- to_jsonb(p_display_name)
- ) ||
- jsonb_set(
- COALESCE(raw_user_meta_data, '{}'::jsonb),
- '{phone_number}',
- to_jsonb(p_phone_number)
- )
- )
- WHERE id = v_target_user_id;
- END IF;
-
- -- Audit log: record the action
- PERFORM log_admin_action(jsonb_build_object(
- 'action_type', 'update',
- 'admin_id', auth.uid(),
- 'admin_email', v_admin_email,
- 'affected_user_id', v_target_user_id,
- 'brand_id', COALESCE(p_brand_id, v_target_brand_id),
- 'details', jsonb_build_object(
- 'changed_fields', ARRAY_REMOVE(ARRAY[
- CASE WHEN p_role IS NOT NULL THEN 'role' ELSE NULL END,
- CASE WHEN p_brand_id IS NOT NULL THEN 'brand_id' ELSE NULL END,
- CASE WHEN p_flags IS NOT NULL THEN 'flags' ELSE NULL END,
- CASE WHEN p_active IS NOT NULL THEN 'active' ELSE NULL END,
- CASE WHEN p_display_name IS NOT NULL THEN 'display_name' ELSE NULL END,
- CASE WHEN p_phone_number IS NOT NULL THEN 'phone_number' ELSE NULL END
- ], NULL)
- )
- ));
-
- RETURN QUERY
- SELECT
- au.id, au.user_id,
- COALESCE(
- (au.raw_user_meta_data->>'display_name')::TEXT,
- u.email
- ) AS display_name,
- u.email, au.role::TEXT, au.brand_id, au.active,
- au.phone_number, NOW() AS updated_at
- FROM admin_users au
- JOIN auth.users u ON au.user_id = u.id
- WHERE au.id = p_id;
-END;
-$$;
-
--- Update create_admin_user to also log
-CREATE OR REPLACE FUNCTION create_admin_user(
- p_email TEXT,
- p_role TEXT,
- p_brand_id UUID DEFAULT NULL,
- p_flags JSONB DEFAULT '{}'::JSONB
-)
-RETURNS TABLE (
- id UUID,
- user_id UUID,
- display_name TEXT,
- email TEXT,
- role TEXT,
- brand_id UUID,
- active BOOLEAN,
- created_at TIMESTAMPTZ
-)
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_caller_brand_id UUID;
- v_caller_can_manage_users BOOLEAN;
- v_target_user_id UUID;
- v_new_id UUID;
- v_admin_email TEXT;
-BEGIN
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- SELECT role, brand_id, can_manage_users
- INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
- FROM admin_users
- WHERE user_id = auth.uid();
-
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
- END IF;
-
- IF v_caller_role = 'brand_admin' THEN
- IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
- RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
- END IF;
- IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
- RAISE EXCEPTION 'brand_admin cannot create platform_admin';
- END IF;
- END IF;
-
- BEGIN
- SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
- EXCEPTION WHEN OTHERS THEN
- RAISE EXCEPTION 'Auth user with email % not found', p_email;
- END;
-
- IF v_target_user_id IS NULL THEN
- RAISE EXCEPTION 'Auth user with email % not found', p_email;
- END IF;
-
- IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
- RAISE EXCEPTION 'Admin user with email % already exists', p_email;
- END IF;
-
- -- Get caller email for audit log
- SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
-
- INSERT INTO admin_users (
- user_id, role, brand_id,
- 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, active
- ) VALUES (
- v_target_user_id, p_role, p_brand_id,
- COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
- COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
- true
- )
- RETURNING id INTO v_new_id;
-
- -- Audit log: record the creation
- PERFORM log_admin_action(jsonb_build_object(
- 'action_type', 'create',
- 'admin_id', auth.uid(),
- 'admin_email', v_admin_email,
- 'affected_user_id', v_target_user_id,
- 'brand_id', p_brand_id,
- 'details', jsonb_build_object('new_role', p_role)
- ));
-
- RETURN QUERY
- SELECT
- au.id, au.user_id,
- COALESCE(
- (au.raw_user_meta_data->>'display_name')::TEXT,
- (au.raw_user_meta_data->>'full_name')::TEXT,
- u.email
- ) AS display_name,
- u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
- FROM admin_users au
- JOIN auth.users u ON au.user_id = u.id
- WHERE au.id = v_new_id;
-END;
-$$;
-
--- Update delete_admin_user to also log
-CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
-RETURNS BOOLEAN
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
-AS $$
-DECLARE
- v_caller_role TEXT;
- v_target_user_id UUID;
- v_admin_email TEXT;
- v_target_brand_id UUID;
-BEGIN
- IF auth.uid() IS NULL THEN
- RAISE EXCEPTION 'Not authenticated';
- END IF;
-
- SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
- IF v_caller_role IS NULL THEN
- RAISE EXCEPTION 'Not an admin';
- END IF;
-
- SELECT user_id, brand_id INTO v_target_user_id, v_target_brand_id
- FROM admin_users WHERE id = p_id;
-
- SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
-
- IF v_target_user_id = auth.uid() THEN
- RAISE EXCEPTION 'Cannot delete your own admin account';
- END IF;
-
- IF v_caller_role = 'brand_admin' THEN
- IF NOT EXISTS (
- SELECT 1 FROM admin_users
- WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
- ) THEN
- RAISE EXCEPTION 'Insufficient permissions to delete this user';
- END IF;
- END IF;
-
- -- Audit log: record the deletion
- PERFORM log_admin_action(jsonb_build_object(
- 'action_type', 'delete',
- 'admin_id', auth.uid(),
- 'admin_email', v_admin_email,
- 'affected_user_id', v_target_user_id,
- 'brand_id', v_target_brand_id,
- 'details', jsonb_build_object('deleted_admin_id', p_id)
- ));
-
- DELETE FROM admin_users WHERE id = p_id;
- RETURN true;
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 038_product_images.sql
--- ───────────────────────────────────────────
--- Migration 038: Product Images + Public Site Polish
--- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION
-
--- ── 1. Add image_url to products ───────────────────────────────────────────────
-ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url TEXT;
-
--- ── 2. Update get_stop_products to include image_url ──────────────────────────
-CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- RETURN jsonb_build_object('products', (
- SELECT COALESCE(jsonb_agg(
- jsonb_build_object(
- 'id', ps.id,
- 'product_id', ps.product_id,
- 'name', p.name,
- 'type', p.type,
- 'price', p.price,
- 'image_url', p.image_url
- )
- ), '[]'::JSONB)
- FROM product_stops ps
- JOIN products p ON p.id = ps.product_id
- WHERE ps.stop_id = p_stop_id
- ));
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 039_shipping_fulfillment.sql
--- ───────────────────────────────────────────
--- Migration 039: Shipping Fulfillment Fields
--- Idempotent: ADD COLUMN IF NOT EXISTS
-
-ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_status TEXT NOT NULL DEFAULT 'pending';
-ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT;
-
-COMMENT ON COLUMN orders.shipping_status IS 'pending | label_created | shipped | delivered | returned';
-COMMENT ON COLUMN orders.tracking_number IS 'Carrier tracking number';
-
--- ───────────────────────────────────────────
--- 040_shipping_fulfillment_rpcs.sql
--- ───────────────────────────────────────────
--- Migration 040: Shipping Fulfillment RPCs
--- Idempotent: CREATE OR REPLACE FUNCTION
-
--- ── 1. update_shipping_order ──────────────────────────────────────────────────
--- Updates shipping_status and tracking_number for an order.
--- Requires can_manage_orders permission (checked in server action via getAdminUser).
-
-CREATE OR REPLACE FUNCTION public.update_shipping_order(
- p_order_id UUID,
- p_shipping_status TEXT,
- p_tracking_number TEXT DEFAULT NULL
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- UPDATE orders SET
- shipping_status = p_shipping_status,
- tracking_number = p_tracking_number,
- updated_at = now()
- WHERE id = p_order_id;
-
- IF NOT FOUND THEN
- RETURN jsonb_build_object('success', false, 'error', 'Order not found');
- END IF;
-
- RETURN jsonb_build_object('success', true);
-END;
-$$;
-
--- ── 2. get_shipping_orders ───────────────────────────────────────────────────
--- Returns orders that contain at least one shipping-line item.
--- Filtered by brand_id when p_brand_id is provided.
-
-CREATE OR REPLACE FUNCTION public.get_shipping_orders(p_brand_id UUID DEFAULT NULL)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_result JSONB;
-BEGIN
- SELECT jsonb_agg(
- jsonb_build_object(
- 'id', o.id,
- 'customer_name', o.customer_name,
- 'customer_email', o.customer_email,
- 'customer_phone', o.customer_phone,
- 'status', o.status,
- 'subtotal', o.subtotal,
- 'shipping_status', o.shipping_status,
- 'tracking_number', o.tracking_number,
- 'created_at', o.created_at,
- 'brand_id', o.brand_id,
- 'order_items', COALESCE((
- SELECT jsonb_agg(jsonb_build_object(
- 'id', oi.id,
- 'product_id', oi.product_id,
- 'quantity', oi.quantity,
- 'price', oi.price,
- 'fulfillment', oi.fulfillment,
- 'products', jsonb_build_object('name', p.name)
- ))
- FROM order_items oi
- JOIN products p ON oi.product_id = p.id
- WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
- ), '[]'::JSONB)
- )
- )
- INTO v_result
- FROM orders o
- WHERE o.id IN (
- SELECT DISTINCT oi.order_id
- FROM order_items oi
- WHERE oi.fulfillment = 'shipping'
- )
- AND (
- p_brand_id IS NULL
- OR o.brand_id = p_brand_id
- )
- ORDER BY o.created_at DESC;
-
- RETURN COALESCE(v_result, '[]'::JSONB);
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 041_payment_settings.sql
--- ───────────────────────────────────────────
--- Migration 041: Payment Settings
--- Creates payment_settings table and RPCs for provider configuration.
-
--- ── 1. payment_settings table ─────────────────────────────────────────────────
-CREATE TABLE IF NOT EXISTS public.payment_settings (
- id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
- brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
- provider TEXT, -- stripe | square | manual
- stripe_publishable_key TEXT,
- stripe_secret_key TEXT,
- square_access_token TEXT,
- square_location_id TEXT,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE(brand_id)
-);
-
--- ── 2. RLS ──────────────────────────────────────────────────────────────────
-ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY;
-
-DROP POLICY IF EXISTS "Brand admin can read payment_settings" ON public.payment_settings;
-CREATE POLICY "Brand admin can read payment_settings"
- ON public.payment_settings FOR SELECT TO authenticated
- USING (
- brand_id IN (
- SELECT brand_id FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'brand_admin'
- )
- );
-
-DROP POLICY IF EXISTS "Platform admin can read payment_settings" ON public.payment_settings;
-CREATE POLICY "Platform admin can read payment_settings"
- ON public.payment_settings FOR SELECT TO authenticated
- USING (
- EXISTS (
- SELECT 1 FROM admin_users
- WHERE admin_users.user_id = auth.uid()
- AND admin_users.role = 'platform_admin'
- )
- );
-
--- Writes go through SECURITY DEFINER helpers only.
-
--- ── 3. get_payment_settings ──────────────────────────────────────────────────
-CREATE OR REPLACE FUNCTION public.get_payment_settings(p_brand_id UUID)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_result JSONB;
-BEGIN
- SELECT jsonb_build_object(
- 'id', id,
- 'brand_id', brand_id,
- 'provider', provider,
- 'stripe_publishable_key', stripe_publishable_key,
- 'stripe_secret_key', stripe_secret_key,
- 'square_access_token', square_access_token,
- 'square_location_id', square_location_id,
- 'updated_at', updated_at::TEXT
- ) INTO v_result
- FROM payment_settings
- WHERE brand_id = p_brand_id;
-
- RETURN v_result;
-END;
-$$;
-
--- ── 4. upsert_payment_settings ────────────────────────────────────────────────
-CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
- p_brand_id UUID,
- p_provider TEXT,
- p_stripe_publishable_key TEXT DEFAULT NULL,
- p_stripe_secret_key TEXT DEFAULT NULL,
- p_square_access_token TEXT DEFAULT NULL,
- p_square_location_id TEXT DEFAULT NULL
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-BEGIN
- INSERT INTO payment_settings (
- brand_id, provider,
- stripe_publishable_key, stripe_secret_key,
- square_access_token, square_location_id
- )
- VALUES (
- p_brand_id, p_provider,
- p_stripe_publishable_key, p_stripe_secret_key,
- p_square_access_token, p_square_location_id
- )
- ON CONFLICT (brand_id) DO UPDATE SET
- provider = EXCLUDED.provider,
- stripe_publishable_key = EXCLUDED.stripe_publishable_key,
- stripe_secret_key = EXCLUDED.stripe_secret_key,
- square_access_token = EXCLUDED.square_access_token,
- square_location_id = EXCLUDED.square_location_id,
- updated_at = now();
-
- RETURN jsonb_build_object('success', true);
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
--- ───────────────────────────────────────────
--- 042_product_import_rpc.sql
--- ───────────────────────────────────────────
--- Migration 042: Product Import RPC + upsert_product
--- Idempotent: CREATE OR REPLACE FUNCTION
-
--- ── 1. upsert_product ─────────────────────────────────────────────────────────
--- Upserts a single product by name (within brand). Returns the product id.
--- Used by the CSV import tool to create or update products in bulk.
-
-CREATE OR REPLACE FUNCTION public.upsert_product(
- p_brand_id UUID,
- p_name TEXT,
- p_price NUMERIC,
- p_type TEXT,
- p_description TEXT DEFAULT '',
- p_active BOOLEAN DEFAULT true,
- p_image_url TEXT DEFAULT NULL
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_product_id UUID;
- v_is_update BOOLEAN := false;
-BEGIN
- -- Check if product with same name exists in this brand
- SELECT id INTO v_product_id
- FROM products
- WHERE brand_id = p_brand_id AND name = p_name
- LIMIT 1;
-
- IF v_product_id IS NOT NULL THEN
- -- Update existing
- UPDATE products SET
- name = p_name,
- description = p_description,
- price = p_price,
- type = p_type,
- active = p_active,
- image_url = COALESCE(p_image_url, image_url),
- updated_at = now()
- WHERE id = v_product_id
- RETURNING id INTO v_product_id;
- v_is_update := true;
- ELSE
- -- Insert new
- INSERT INTO products (brand_id, name, description, price, type, active, image_url)
- VALUES (p_brand_id, p_name, p_description, p_price, p_type, p_active, p_image_url)
- RETURNING id INTO v_product_id;
- END IF;
-
- RETURN jsonb_build_object(
- 'id', v_product_id,
- 'is_update', v_is_update,
- 'name', p_name
- );
-END;
-$$;
-
--- ── 2. bulk_upsert_products ──────────────────────────────────────────────────
--- Takes an array of product records and upserts them all within a brand.
--- Returns a summary: { created, updated, errors }.
-
-CREATE OR REPLACE FUNCTION public.bulk_upsert_products(
- p_brand_id UUID,
- p_products JSONB -- array of { name, description, price, type, active, image_url }
-)
-RETURNS JSONB
-LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
-AS $$
-DECLARE
- v_entry JSONB;
- v_result JSONB := '{"created": 0, "updated": 0, "errors": []}'::JSONB;
- v_name TEXT;
- v_desc TEXT;
- v_price NUMERIC;
- v_type TEXT;
- v_active BOOLEAN;
- v_img TEXT;
- v_was_new BOOLEAN;
-BEGIN
- FOR v_entry IN SELECT * FROM jsonb_array_elements(p_products)
- LOOP
- BEGIN
- v_name := nullif(v_entry->>'name', '');
- v_desc := coalesce(nullif(v_entry->>'description', ''), '');
- v_price := nullif((v_entry->>'price')::TEXT, '')::NUMERIC;
- v_type := nullif(v_entry->>'type', '');
- v_active := coalesce((v_entry->>'active')::BOOLEAN, true);
- v_img := nullif(v_entry->>'image_url', '');
-
- IF v_name IS NULL OR v_name = '' THEN
- RAISE EXCEPTION 'Product name is required';
- END IF;
-
- IF v_price IS NULL OR v_price < 0 THEN
- RAISE EXCEPTION 'Invalid price for product: %', v_name;
- END IF;
-
- IF v_type NOT IN ('Pickup', 'Shipping', 'Pickup & Shipping') THEN
- RAISE EXCEPTION 'Invalid type for product: %. Must be Pickup, Shipping, or Pickup & Shipping', v_name;
- END IF;
-
- v_was_new := NOT EXISTS (
- SELECT 1 FROM products WHERE brand_id = p_brand_id AND name = v_name
- );
-
- PERFORM upsert_product(p_brand_id, v_name, v_desc, v_price, v_type, v_active, v_img);
-
- IF v_was_new THEN
- v_result := jsonb_set(v_result, '{created}',
- to_jsonb((v_result->>'created')::INTEGER + 1));
- ELSE
- v_result := jsonb_set(v_result, '{updated}',
- to_jsonb((v_result->>'updated')::INTEGER + 1));
- END IF;
-
- EXCEPTION WHEN OTHERS THEN
- v_result := jsonb_set(
- v_result, '{errors}',
- v_result->'errors' || jsonb_build_array(
- jsonb_build_object('product', v_name, 'error', SQLERRM)
- )
- );
- END;
- END LOOP;
-
- RETURN v_result;
-END;
-$$;
-
-NOTIFY pgrst, 'reload schema';
-
diff --git a/supabase/migrations/XXX_blog_tables.sql b/supabase/migrations/XXX_blog_tables.sql
deleted file mode 100644
index bb912a5..0000000
--- a/supabase/migrations/XXX_blog_tables.sql
+++ /dev/null
@@ -1,77 +0,0 @@
--- Blog and Newsletter Tables
--- Migration for Route Commerce
-
-CREATE TABLE IF NOT EXISTS blog_posts (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- slug TEXT UNIQUE NOT NULL,
- title TEXT NOT NULL,
- excerpt TEXT,
- content TEXT,
- featured_image TEXT,
- author_name TEXT NOT NULL,
- author_avatar TEXT,
- category TEXT DEFAULT 'General',
- tags TEXT[],
- published_at TIMESTAMPTZ,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
- published BOOLEAN DEFAULT FALSE
-);
-
-CREATE TABLE IF NOT EXISTS blog_resources (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- title TEXT NOT NULL,
- description TEXT,
- type TEXT DEFAULT 'guide' CHECK (type IN ('guide', 'case_study', 'webinar', 'template', 'checklist')),
- url TEXT,
- thumbnail TEXT,
- downloads INTEGER DEFAULT 0,
- featured BOOLEAN DEFAULT FALSE,
- created_at TIMESTAMPTZ DEFAULT NOW()
-);
-
-CREATE TABLE IF NOT EXISTS newsletter_subscribers (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- email TEXT UNIQUE NOT NULL,
- subscribed_at TIMESTAMPTZ DEFAULT NOW(),
- status TEXT DEFAULT 'active' CHECK (status IN ('active', 'unsubscribed', 'bounced')),
- source TEXT,
- unsubscribed_at TIMESTAMPTZ
-);
-
--- Indexes
-CREATE INDEX IF NOT EXISTS idx_blog_slug ON blog_posts(slug);
-CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published);
-CREATE INDEX IF NOT EXISTS idx_blog_category ON blog_posts(category);
-CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email);
-
--- Grant permissions
-GRANT SELECT, INSERT ON blog_posts TO anon;
-GRANT SELECT, INSERT ON blog_resources TO anon;
-GRANT SELECT, INSERT ON newsletter_subscribers TO anon;
-GRANT ALL ON blog_posts TO authenticated;
-GRANT ALL ON blog_resources TO authenticated;
-GRANT ALL ON newsletter_subscribers TO authenticated;
-GRANT ALL ON blog_posts TO service_role;
-GRANT ALL ON blog_resources TO service_role;
-GRANT ALL ON newsletter_subscribers TO service_role;
-
--- Seed blog posts
-INSERT INTO blog_posts (slug, title, excerpt, content, author_name, category, published_at, published) VALUES
- ('getting-started-with-route-commerce', 'Getting Started with Route Commerce', 'Learn how to set up your wholesale business on Route Commerce in under 10 minutes.', '## Getting Started
-
-Welcome to Route Commerce! This guide will walk you through setting up your wholesale operation...', 'Team Route Commerce', 'Guides', NOW(), TRUE),
- ('maximize-produce-profitability', '5 Tips to Maximize Your Produce Profitability', 'Strategic pricing and inventory management can significantly boost your bottom line.', '## Introduction
-
-Running a profitable produce operation requires more than just quality products...', 'Sarah Johnson', 'Tips', NOW(), TRUE),
- ('customer-communication-best-practices', 'Best Practices for Customer Communication', 'Keep your customers informed and engaged with these communication strategies.', '## Why Communication Matters
-
-Clear, timely communication builds trust and reduces missed pickups...', 'Marcus Chen', 'Marketing', NOW(), TRUE)
-ON CONFLICT DO NOTHING;
-
--- Seed resources
-INSERT INTO blog_resources (title, description, type, downloads, featured) VALUES
- ('Wholesale Pricing Guide', 'Complete guide to setting profitable wholesale prices', 'guide', 342, TRUE),
- ('Order Management Checklist', 'Step-by-step checklist for order fulfillment', 'checklist', 256, FALSE),
- ('Customer Email Templates', 'Pre-written email templates for common scenarios', 'template', 189, FALSE)
-ON CONFLICT DO NOTHING;
\ No newline at end of file
diff --git a/supabase/migrations/XXX_launch_checklist.sql b/supabase/migrations/XXX_launch_checklist.sql
deleted file mode 100644
index c65afa4..0000000
--- a/supabase/migrations/XXX_launch_checklist.sql
+++ /dev/null
@@ -1,22 +0,0 @@
--- Launch Checklist Progress Table
--- Track user's launch preparation progress
-
-CREATE TABLE IF NOT EXISTS launch_checklist_progress (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id TEXT NOT NULL,
- item_id TEXT NOT NULL,
- completed BOOLEAN DEFAULT FALSE,
- completed_at TIMESTAMPTZ,
- notes TEXT,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW(),
- UNIQUE(user_id, item_id)
-);
-
--- Indexes
-CREATE INDEX IF NOT EXISTS idx_checklist_user ON launch_checklist_progress(user_id);
-CREATE INDEX IF NOT EXISTS idx_checklist_completed ON launch_checklist_progress(completed) WHERE completed = TRUE;
-
--- Grant permissions
-GRANT SELECT, INSERT, UPDATE ON launch_checklist_progress TO authenticated;
-GRANT ALL ON launch_checklist_progress TO service_role;
\ No newline at end of file
diff --git a/supabase/migrations/XXX_roadmap_tables.sql b/supabase/migrations/XXX_roadmap_tables.sql
deleted file mode 100644
index b9ea10b..0000000
--- a/supabase/migrations/XXX_roadmap_tables.sql
+++ /dev/null
@@ -1,45 +0,0 @@
--- Roadmap Tables for Feature Requests and Voting
--- Migration for Route Commerce
-
-CREATE TABLE IF NOT EXISTS roadmap_items (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- title TEXT NOT NULL,
- description TEXT,
- status TEXT DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'shipped')),
- category TEXT,
- upvotes INTEGER DEFAULT 0,
- created_by TEXT,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW()
-);
-
-CREATE TABLE IF NOT EXISTS roadmap_votes (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- item_id UUID REFERENCES roadmap_items(id) ON DELETE CASCADE,
- visitor_id TEXT NOT NULL,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- UNIQUE(item_id, visitor_id)
-);
-
--- Indexes
-CREATE INDEX IF NOT EXISTS idx_roadmap_status ON roadmap_items(status);
-CREATE INDEX IF NOT EXISTS idx_roadmap_category ON roadmap_items(category);
-CREATE INDEX IF NOT EXISTS idx_roadmap_upvotes ON roadmap_items(upvotes DESC);
-CREATE INDEX IF NOT EXISTS idx_roadmap_votes_item ON roadmap_votes(item_id);
-
--- Grant permissions
-GRANT SELECT ON roadmap_items TO anon;
-GRANT SELECT, INSERT ON roadmap_votes TO anon;
-GRANT ALL ON roadmap_items TO authenticated;
-GRANT ALL ON roadmap_votes TO authenticated;
-GRANT ALL ON roadmap_items TO service_role;
-GRANT ALL ON roadmap_votes TO service_role;
-
--- Seed some initial items
-INSERT INTO roadmap_items (title, description, status, category, upvotes) VALUES
- ('Mobile App (iOS & Android)', 'Native apps for field workers and delivery drivers', 'in_progress', 'Mobile', 234),
- ('SMS Campaigns', 'Text message marketing and notifications', 'planned', 'Communication', 98),
- ('Route Optimization', 'AI-powered route planning for deliveries', 'planned', 'Logistics', 167),
- ('Customer Loyalty Program', 'Points, rewards, and referral tracking', 'planned', 'Marketing', 112),
- ('POS Integration (Clover, Toast)', 'Additional POS system integrations', 'planned', 'Integrations', 76)
-ON CONFLICT DO NOTHING;
\ No newline at end of file
diff --git a/supabase/migrations/XXX_waitlist_table.sql b/supabase/migrations/XXX_waitlist_table.sql
deleted file mode 100644
index f29557f..0000000
--- a/supabase/migrations/XXX_waitlist_table.sql
+++ /dev/null
@@ -1,30 +0,0 @@
--- Waitlist and Early Access Signup System
--- Migration for Route Commerce
-
-CREATE TABLE IF NOT EXISTS waitlist (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- email TEXT UNIQUE NOT NULL,
- name TEXT,
- referral_source TEXT,
- referred_by TEXT,
- created_at TIMESTAMPTZ DEFAULT NOW(),
- status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'converted', 'archived'))
-);
-
--- Index for email lookups
-CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email);
-CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
-CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
-
--- Grant permissions
-GRANT SELECT, INSERT ON waitlist TO anon;
-GRANT SELECT, INSERT ON waitlist TO authenticated;
-GRANT SELECT, INSERT ON waitlist TO service_role;
-
--- Comment on table
-COMMENT ON TABLE waitlist IS 'Early access signup for Route Commerce platform';
-COMMENT ON COLUMN waitlist.email IS 'Unique email address';
-COMMENT ON COLUMN waitlist.name IS 'Optional full name';
-COMMENT ON COLUMN waitlist.referral_source IS 'How they heard about us';
-COMMENT ON COLUMN waitlist.referred_by IS 'Email of person who referred them';
-COMMENT ON COLUMN waitlist.status IS 'pending, converted (signed up), archived';
\ No newline at end of file