migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)

This commit is contained in:
2026-06-07 03:14:59 +00:00
parent b8317a200e
commit eb9621d238
17 changed files with 911 additions and 1053 deletions
+20 -26
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type AuditAction = "INSERT" | "UPDATE" | "DELETE"; export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
@@ -22,12 +22,10 @@ type AuditResult =
* Logs an audit event to the audit_logs table. * Logs an audit event to the audit_logs table.
* *
* Resolves the admin user from the Auth.js session via getAdminUser(). * Resolves the admin user from the Auth.js session via getAdminUser().
* Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function. * Audit writes go through the `log_audit_event` SECURITY DEFINER
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
*/ */
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> { export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const performed_by = adminUser?.user_id ?? null; const performed_by = adminUser?.user_id ?? null;
@@ -49,26 +47,22 @@ export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult>
brand_id: payload.brand_id ?? null, brand_id: payload.brand_id ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/log_audit_event`, const { rows } = await pool.query<{ id?: string }>(
{ "SELECT * FROM log_audit_event($1::jsonb)",
method: "POST", [JSON.stringify(rpcPayload)],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, );
body: JSON.stringify({ p_payload: rpcPayload }), const auditId = typeof rows[0] === "string"
? rows[0]
: rows[0]?.id;
if (!auditId) {
return { success: false, error: "Audit log written but ID not returned" };
} }
); return { success: true, audit_id: auditId };
} catch (err) {
if (!response.ok) { return {
const err = await response.json().catch(() => ({ message: "Unknown error" })); success: false,
return { success: false, error: err.message ?? "Failed to write audit log" }; error: err instanceof Error ? err.message : "Failed to write audit log",
};
} }
}
const data = await response.json();
const auditId = typeof data === "string" ? data : data?.[0]?.id ?? data?.id;
if (!auditId) {
return { success: false, error: "Audit log written but ID not returned" };
}
return { success: true, audit_id: auditId };
}
+124 -136
View File
@@ -1,12 +1,23 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type UploadLogoResult = export type UploadLogoResult =
| { success: true; logoUrl: string } | { success: true; logoUrl: string }
| { success: false; error: string }; | { success: false; error: string };
/**
* Upload a brand logo (light or dark variant) to Supabase Storage and
* save the resulting public URL to the brand_settings row via the
* `upsert_brand_settings` SECURITY DEFINER RPC.
*
* NOTE: the storage PUT itself is not a database call, so it still
* goes through the Supabase Storage REST API. Storage migration to an
* S3-compatible backend is tracked separately; until that lands, the
* public URL pattern (`/storage/v1/object/public/...`) keeps working
* for any pre-existing bucket.
*/
export async function uploadBrandLogo( export async function uploadBrandLogo(
brandId: string, brandId: string,
file: File, file: File,
@@ -43,7 +54,7 @@ export async function uploadBrandLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -54,20 +65,10 @@ export async function uploadBrandLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
// Save URL to brand_settings via RPC const saveOk = await callUpsertBrandSettings(brandId, {
const saveRes = await fetch( [isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, });
{ if (!saveOk) {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -108,7 +109,7 @@ export async function uploadOlatheSweetLogo(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -119,19 +120,10 @@ export async function uploadOlatheSweetLogo(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, p_olathe_sweet_logo_url: publicUrl,
{ });
method: "POST", if (!saveOk) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
@@ -172,7 +164,7 @@ export async function uploadOlatheSweetLogoDark(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`, `${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{ {
method: "PUT", method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" }, headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
body: buffer, body: buffer,
} }
); );
@@ -183,25 +175,45 @@ export async function uploadOlatheSweetLogoDark(
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`; const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveRes = await fetch( const saveOk = await callUpsertBrandSettings(brandId, {
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, p_olathe_sweet_logo_url_dark: publicUrl,
{ });
method: "POST", if (!saveOk) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url_dark: publicUrl,
}),
}
);
if (!saveRes.ok) {
return { success: false, error: "Upload succeeded but failed to save URL" }; return { success: false, error: "Upload succeeded but failed to save URL" };
} }
return { success: true, logoUrl: publicUrl }; return { success: true, logoUrl: publicUrl };
} }
/**
* Internal helper: invoke the SECURITY DEFINER `upsert_brand_settings`
* RPC via the shared pg pool. Accepts a partial args object whose keys
* map to the function's `p_*` parameters.
*/
async function callUpsertBrandSettings(
brandId: string,
args: Record<string, unknown>
): Promise<boolean> {
// Always set the brand id.
args.p_brand_id = brandId;
// Build a `$1, $2, ...` parameter list in deterministic key order so
// the positional binds line up with the named parameters.
const keys = Object.keys(args);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(", ");
const params = keys.map((k) => args[k]);
try {
await pool.query(
`SELECT upsert_brand_settings(${placeholders})`,
params,
);
return true;
} catch {
return false;
}
}
export type BrandSettings = { export type BrandSettings = {
id?: string; id?: string;
brand_id: string; brand_id: string;
@@ -252,51 +264,35 @@ export type SaveBrandSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> { export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
"SELECT * FROM get_brand_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_brand_settings`, );
{ return { success: true, settings: rows[0] ?? null };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to fetch brand settings" };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch brand settings" };
const data = await response.json();
return { success: true, settings: data };
} }
// Public version for storefront pages — uses slug, no auth required // Public version for storefront pages — uses slug, no auth required
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> { export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just falls back to its default brand
// name and revalidates from a real request later.
if (!supabaseUrl || !supabaseKey) {
return { success: false, error: "Supabase not configured", wholesaleEnabled: undefined };
}
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just falls back to its
// default brand name and revalidates from a real request later.
try { try {
const response = await fetch( const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, "SELECT * FROM get_brand_settings_by_slug($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
}
); );
const data = rows[0];
if (!response.ok) return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; if (!data) {
const data = await response.json(); return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
}
return { return {
success: true, success: true,
settings: data, settings: data,
wholesaleEnabled: data?.wholesale_enabled, wholesaleEnabled: data.wholesale_enabled,
}; };
} catch { } catch {
return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined }; return { success: false, error: "Failed to fetch brand settings", wholesaleEnabled: undefined };
@@ -348,60 +344,52 @@ export async function saveBrandSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<BrandSettings>(
`SELECT * FROM upsert_brand_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
{ $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
method: "POST", $31, $32
headers: { )`,
...svcHeaders(supabaseKey), [
"Content-Type": "application/json", params.brandId,
Prefer: "return=representation", params.legalBusinessName ?? null,
}, params.phone ?? null,
body: JSON.stringify({ params.email ?? null,
p_brand_id: params.brandId, params.websiteUrl ?? null,
p_legal_business_name: params.legalBusinessName ?? null, params.streetAddress ?? null,
p_phone: params.phone ?? null, params.city ?? null,
p_email: params.email ?? null, params.state ?? null,
p_website_url: params.websiteUrl ?? null, params.postalCode ?? null,
p_street_address: params.streetAddress ?? null, params.country ?? null,
p_city: params.city ?? null, params.logoUrl ?? null,
p_state: params.state ?? null, params.logoUrlDark ?? null,
p_postal_code: params.postalCode ?? null, params.olatheSweetLogoUrl ?? null,
p_country: params.country ?? null, params.olatheSweetLogoUrlDark ?? null,
p_logo_url: params.logoUrl ?? null, params.defaultEmailSignature ?? null,
p_logo_url_dark: params.logoUrlDark ?? null, params.invoiceFooterNotes ?? null,
p_olathe_sweet_logo_url: params.olatheSweetLogoUrl ?? null, params.heroTagline ?? null,
p_olathe_sweet_logo_url_dark: params.olatheSweetLogoUrlDark ?? null, params.aboutHeadline ?? null,
p_default_email_signature: params.defaultEmailSignature ?? null, params.aboutSubheadline ?? null,
p_invoice_footer_notes: params.invoiceFooterNotes ?? null, params.customFooterText ?? null,
p_hero_tagline: params.heroTagline ?? null, params.showWholesaleLink ?? null,
p_about_headline: params.aboutHeadline ?? null, params.showZipSearch ?? null,
p_about_subheadline: params.aboutSubheadline ?? null, params.showSchedulePdf ?? null,
p_custom_footer_text: params.customFooterText ?? null, params.showTextAlerts ?? null,
p_show_wholesale_link: params.showWholesaleLink ?? null, params.schedulePdfNotes ?? null,
p_show_zip_search: params.showZipSearch ?? null, params.heroImageUrl ?? null,
p_show_schedule_pdf: params.showSchedulePdf ?? null, params.brandPrimaryColor ?? null,
p_show_text_alerts: params.showTextAlerts ?? null, params.brandSecondaryColor ?? null,
p_schedule_pdf_notes: params.schedulePdfNotes ?? null, params.brandBgColor ?? null,
p_hero_image_url: params.heroImageUrl ?? null, params.brandTextColor ?? null,
p_brand_primary_color: params.brandPrimaryColor ?? null, params.collectSalesTax ?? null,
p_brand_secondary_color: params.brandSecondaryColor ?? null, params.nexusStates ?? null,
p_brand_bg_color: params.brandBgColor ?? null, ],
p_brand_text_color: params.brandTextColor ?? null, );
p_collect_sales_tax: params.collectSalesTax ?? null, return { success: true, settings: rows[0] as BrandSettings };
p_nexus_states: params.nexusStates ?? null, } catch (err) {
}), const message = err instanceof Error ? err.message : "Failed to save";
} return { success: false, error: `Failed to save: ${message.slice(0, 200)}` };
);
if (!response.ok) {
const err = await response.text();
return { success: false, error: `Failed to save: ${err.slice(0, 200)}` };
} }
}
const data = await response.json();
return { success: true, settings: data };
}
+144 -155
View File
@@ -2,10 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -31,35 +28,6 @@ export type DashboardSummary = {
active_products: number; active_products: number;
}; };
// ── Helper ────────────────────────────────────────────────────────────────────
async function brandScopedFetch<T>(
endpoint: string,
params?: Record<string, string>
): Promise<T> {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
let url = `${supabaseUrl}/rest/v1${endpoint}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams.toString()}`;
}
const response = await fetch(url, {
headers: {
...svcHeaders(supabaseKey),
},
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Dashboard fetch failed: ${err}`);
}
return response.json() as Promise<T>;
}
// ── Dashboard Stats Actions ───────────────────────────────────────────────── // ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> { export async function getDashboardStats(): Promise<DashboardStats> {
@@ -74,58 +42,69 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
// Get start of week (7 days ago) - kept for potential weekly comparison // Fetch today's orders. `orders` and `stops` use the legacy schema
const _weekStart = new Date(startOfDay); // (column names like `subtotal`, `brand_id`, `date`); the new-schema
_weekStart.setDate(_weekStart.getDate() - 6); // Drizzle `orders` table doesn't have these. Raw SQL via the shared
// pg pool.
// Fetch today's orders const todayOrdersRes = brandId
let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`; ? await pool.query<{ subtotal: number; status: string }>(
todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`; `SELECT subtotal, status
todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`; FROM orders
if (brandId) { WHERE created_at >= $1
todayOrdersQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3`,
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
const todayOrdersRes = await fetch(todayOrdersQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ subtotal: number; status: string }>(
}); `SELECT subtotal, status
const todayOrders = await todayOrdersRes.json(); FROM orders
WHERE created_at >= $1
AND created_at < $2`,
[startOfDay.toISOString(), endOfDay.toISOString()],
);
const todayOrders = todayOrdersRes.rows;
// Calculate today's revenue and orders // Calculate today's revenue and orders
const validOrders = (todayOrders as Array<{subtotal: number; status: string}>) const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
.filter(o => o.status !== "cancelled"); const todayRevenue = validOrders.reduce(
const todayRevenue = validOrders (sum, o) => sum + (o.subtotal || 0),
.reduce((sum, o) => sum + (o.subtotal || 0), 0); 0,
);
const todayOrderCount = validOrders.length; const todayOrderCount = validOrders.length;
// Fetch pending stops (stops where date >= today and status is scheduled/upcoming) // Fetch pending stops (stops where date >= today and status is scheduled)
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1
stopsQuery += `&brand_id=eq.${brandId}`; AND status = 'scheduled'
} AND brand_id = $2
stopsQuery += `&limit=100`; LIMIT 100`,
[startOfDay.toISOString().split("T")[0], brandId],
const stopsRes = await fetch(stopsQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM stops
const pendingStopsData = await stopsRes.json(); WHERE date >= $1
const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0; AND status = 'scheduled'
LIMIT 100`,
[startOfDay.toISOString().split("T")[0]],
);
const pendingStops = stopsRes.rows.length;
// Fetch active products // Fetch active products
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`; const productsRes = brandId
productsQuery += `&active=eq.true`; ? await pool.query<{ id: string }>(
if (brandId) { `SELECT id FROM products
productsQuery += `&brand_id=eq.${brandId}`; WHERE active = true AND brand_id = $1
} LIMIT 1000`,
productsQuery += `&limit=1000`; [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: svcHeaders(supabaseKey), `SELECT id FROM products
}); WHERE active = true
const productsData = await productsRes.json(); LIMIT 1000`,
const activeProducts = Array.isArray(productsData) ? productsData.length : 0; );
const activeProducts = productsRes.rows.length;
// Fetch weekly orders for chart (last 7 days) // Fetch weekly orders for chart (last 7 days)
const weeklyOrders: number[] = []; const weeklyOrders: number[] = [];
@@ -134,41 +113,57 @@ export async function getDashboardStats(): Promise<DashboardStats> {
dayStart.setDate(dayStart.getDate() - i); dayStart.setDate(dayStart.getDate() - i);
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`; const dayRes = brandId
dayQuery += `&created_at=gte.${dayStart.toISOString()}`; ? await pool.query<{ id: string }>(
dayQuery += `&created_at=lt.${dayEnd.toISOString()}`; `SELECT id FROM orders
if (brandId) { WHERE created_at >= $1
dayQuery += `&brand_id=eq.${brandId}`; AND created_at < $2
} AND brand_id = $3
dayQuery += `&limit=1`; LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
const dayRes = await fetch(dayQuery, { )
headers: svcHeaders(supabaseKey), : await pool.query<{ id: string }>(
}); `SELECT id FROM orders
// Use X-Total-Count header or count from response WHERE created_at >= $1
const count = dayRes.headers.get("X-Total-Count"); AND created_at < $2
weeklyOrders.push(count ? parseInt(count) : 0); LIMIT 1`,
[dayStart.toISOString(), dayEnd.toISOString()],
);
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
} }
// Fetch recent orders (last 10) // Fetch recent orders (last 10)
let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`; const recentRes = brandId
recentQuery += `&order=created_at.desc`; ? await pool.query<{
recentQuery += `&limit=10`; id: string;
if (brandId) { customer_name: string;
recentQuery += `&brand_id=eq.${brandId}`; subtotal: number;
} status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
WHERE brand_id = $1
ORDER BY created_at DESC
LIMIT 10`,
[brandId],
)
: await pool.query<{
id: string;
customer_name: string;
subtotal: number;
status: string;
created_at: string;
}>(
`SELECT id, customer_name, subtotal, status, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 10`,
);
const recentRes = await fetch(recentQuery, { const recentOrders = recentRes.rows
headers: { .filter((o) => o.status !== "cancelled")
...svcHeaders(supabaseKey), .map((o) => ({
Prefer: "count=exact",
},
});
const recentOrdersData = await recentRes.json();
const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : [])
.filter((o: {status: string}) => o.status !== "cancelled")
.map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({
id: o.id || "", id: o.id || "",
customer_name: o.customer_name || "Guest", customer_name: o.customer_name || "Guest",
total: o.subtotal || 0, total: o.subtotal || 0,
@@ -186,7 +181,6 @@ export async function getDashboardStats(): Promise<DashboardStats> {
}; };
} catch (error) { } catch (error) {
console.error("Failed to fetch dashboard stats:", error); console.error("Failed to fetch dashboard stats:", error);
// Return zeros on error
return { return {
todayOrders: 0, todayOrders: 0,
todayRevenue: 0, todayRevenue: 0,
@@ -207,58 +201,53 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get gross sales from reports RPC // `get_reports_summary` is a SECURITY DEFINER RPC that returns the
const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, { // gross sales + total order counts. Migration 031.
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: thirtyDaysAgo.toISOString().split("T")[0],
p_end_date: new Date().toISOString().split("T")[0],
}),
});
let total_revenue = 0; let total_revenue = 0;
let total_orders = 0; let total_orders = 0;
try {
if (rpcRes.ok) { const { rows } = await pool.query<{
const data = await rpcRes.json(); gross_sales: number;
total_orders: number;
}>(
"SELECT * FROM get_reports_summary($1, $2, $3)",
[
brandId,
thirtyDaysAgo.toISOString().split("T")[0],
new Date().toISOString().split("T")[0],
],
);
const data = rows[0];
total_revenue = data?.gross_sales ?? 0; total_revenue = data?.gross_sales ?? 0;
total_orders = data?.total_orders ?? 0; total_orders = data?.total_orders ?? 0;
} catch {
// Fall through with zeros if the RPC is missing.
} }
// Get active stops count // Get active stops count
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`; const stopsRes = brandId
stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`; ? await pool.query<{ id: string }>(
stopsQuery += `&status=eq.scheduled`; `SELECT id FROM stops
if (brandId) { WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`,
stopsQuery += `&brand_id=eq.${brandId}`; [new Date().toISOString().split("T")[0], brandId],
} )
: await pool.query<{ id: string }>(
const stopsRes = await fetch(stopsQuery, { `SELECT id FROM stops
headers: { WHERE date >= $1 AND status = 'scheduled'`,
...svcHeaders(supabaseKey), [new Date().toISOString().split("T")[0]],
Prefer: "count=exact", );
}, const activeStops = stopsRes.rows.length;
});
const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0");
// Get active products count // Get active products count
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`; const productsRes = brandId
if (brandId) { ? await pool.query<{ id: string }>(
productsQuery += `&brand_id=eq.${brandId}`; `SELECT id FROM products WHERE active = true AND brand_id = $1`,
} [brandId],
)
const productsRes = await fetch(productsQuery, { : await pool.query<{ id: string }>(
headers: { `SELECT id FROM products WHERE active = true`,
...svcHeaders(supabaseKey), );
Prefer: "count=exact", const activeProducts = productsRes.rows.length;
},
});
const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0");
return { return {
total_revenue, total_revenue,
@@ -295,4 +284,4 @@ function formatTimeAgo(dateString: string): string {
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
} }
+106 -137
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type LocationInput = { export type LocationInput = {
name: string; name: string;
@@ -56,39 +56,37 @@ export async function createLocation(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ id: string; slug: string }>(
`SELECT * FROM admin_create_location(
const res = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
`${supabaseUrl}/rest/v1/rpc/admin_create_location`, )`,
{ [
method: "POST", effectiveBrandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, input.name,
body: JSON.stringify({ input.address ?? null,
p_brand_id: effectiveBrandId, input.city ?? null,
p_name: input.name, input.state ?? null,
p_address: input.address ?? null, input.zip ?? null,
p_city: input.city ?? null, input.phone ?? null,
p_state: input.state ?? null, input.contact_name ?? null,
p_zip: input.zip ?? null, input.contact_email ?? null,
p_phone: input.phone ?? null, input.notes ?? null,
p_contact_name: input.contact_name ?? null, ],
p_contact_email: input.contact_email ?? null, );
p_notes: input.notes ?? null, const data = rows[0];
p_active: input.active ?? true, if (!data) {
}), return { success: false, error: "Insert failed" };
} }
); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
if (!res.ok) { return { success: true, id: data.id, slug: data.slug };
const err = await res.json().catch(() => ({ message: "Request failed" })); } catch (err) {
return { success: false, error: (err as { message?: string }).message ?? "Insert failed" }; return {
success: false,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
const data = await res.json();
revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { success: true, id: data.id, slug: data.slug };
} }
// ── Create (batch) ─────────────────────────────────────────────────────────── // ── Create (batch) ───────────────────────────────────────────────────────────
@@ -108,24 +106,21 @@ export async function createLocationsBatch(
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" }; if (!effectiveBrandId) return { success: false, created: 0, error: "No brand selected" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id?: string }[] = [];
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ id?: string }>(
const res = await fetch( "SELECT * FROM admin_create_locations_batch($1, $2::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_create_locations_batch`, [effectiveBrandId, JSON.stringify(locations)],
{ );
method: "POST", inserted = rows;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_locations: locations }), return {
} success: false,
); created: 0,
error: err instanceof Error ? err.message : "Insert failed",
if (!res.ok) { };
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" };
} }
const inserted = await res.json();
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${effectiveBrandId}:locations`, "default"); revalidateTag(`brand:${effectiveBrandId}:locations`, "default");
return { return {
@@ -144,25 +139,23 @@ export async function updateLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_update_location($1, $2, $3::jsonb)",
`${supabaseUrl}/rest/v1/rpc/admin_update_location`, [locationId, brandId, JSON.stringify(updates)],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId, p_updates: updates }), return {
} success: false,
); error: err instanceof Error ? err.message : "Update failed",
};
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Update failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Update failed" }; return { success: false, error: data.error ?? "Update failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -178,25 +171,23 @@ export async function deleteLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_delete_location($1, $2)",
`${supabaseUrl}/rest/v1/rpc/admin_delete_location`, [locationId, brandId],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ p_location_id: locationId, p_brand_id: brandId }), return {
} success: false,
); error: err instanceof Error ? err.message : "Delete failed",
};
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Delete failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("locations", "default"); revalidateTag("locations", "default");
revalidateTag(`brand:${brandId}:locations`, "default"); revalidateTag(`brand:${brandId}:locations`, "default");
@@ -210,27 +201,18 @@ export async function adminListLocations(
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return []; if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Application-layer brand scoping: platform_admin (brand_id: null) gets all
// brands; everyone else gets only their own brand.
const activeBrandId = await getActiveBrandId(adminUser, brandId); const activeBrandId = await getActiveBrandId(adminUser, brandId);
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const res = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/admin_list_locations`, const { rows } = await pool.query<LocationWithCount>(
{ "SELECT * FROM admin_list_locations($1)",
method: "POST", [effectiveBrandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_brand_id: effectiveBrandId }), return Array.isArray(rows) ? rows : [];
next: { revalidate: 60, tags: ["locations", `brand:${effectiveBrandId ?? "all"}:locations`] }, } catch {
} return [];
); }
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as LocationWithCount[]) : [];
} }
// ── Read (public, by brand slug) ───────────────────────────────────────────── // ── Read (public, by brand slug) ─────────────────────────────────────────────
@@ -244,22 +226,15 @@ export async function getPublicLocationsForBrand(
): Promise<PublicLocation[]> { ): Promise<PublicLocation[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<PublicLocation>(
"SELECT * FROM get_locations_for_brand($1)",
const res = await fetch( [brandSlug],
`${supabaseUrl}/rest/v1/rpc/get_locations_for_brand`, );
{ return Array.isArray(rows) ? rows : [];
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return [];
body: JSON.stringify({ p_brand_slug: brandSlug }), }
next: { revalidate: 300, tags: ["locations", `brand:${brandSlug}:locations`] },
}
);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? (data as PublicLocation[]) : [];
} }
// ── Attach a stop to a location ────────────────────────────────────────────── // ── Attach a stop to a location ──────────────────────────────────────────────
@@ -272,29 +247,23 @@ export async function attachStopToLocation(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let data: { success?: boolean; error?: string } = {};
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
const res = await fetch( "SELECT * FROM admin_attach_location_to_stop($1, $2, $3)",
`${supabaseUrl}/rest/v1/rpc/admin_attach_location_to_stop`, [stopId, locationId, brandId],
{ );
method: "POST", data = rows[0] ?? {};
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch (err) {
body: JSON.stringify({ return {
p_stop_id: stopId, success: false,
p_location_id: locationId, error: err instanceof Error ? err.message : "Attach failed",
p_brand_id: brandId, };
}),
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Attach failed" };
} }
const data = await res.json(); if (!data.success) {
if (!data.success) return { success: false, error: data.error ?? "Attach failed" }; return { success: false, error: data.error ?? "Attach failed" };
}
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag("locations", "default"); revalidateTag("locations", "default");
+31 -40
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { mockOrders, mockStops } from "@/lib/mock-data"; import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = { type AdminOrder = {
@@ -149,29 +149,29 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // returns orders + stops joined with order_items. Call it directly via
// the shared pg pool so we don't go through Supabase REST.
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/get_admin_orders`, const { rows } = await pool.query<AdminOrdersResult>(
{ "SELECT * FROM get_admin_orders($1)",
method: "POST", [brandId],
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, );
body: JSON.stringify({ p_brand_id: brandId }), const data = rows[0] ?? { orders: [], stops: [] };
} return {
); success: true,
orders: data.orders ?? [],
if (!response.ok) { stops: data.stops ?? [],
return { success: false, orders: [], stops: [], error: `HTTP ${response.status}: ${response.statusText}` }; error: null,
};
} catch (err) {
return {
success: false,
orders: [],
stops: [],
error: err instanceof Error ? err.message : "Failed to fetch orders",
};
} }
const data = await response.json();
return {
success: true,
orders: data?.orders ?? [],
stops: data?.stops ?? [],
error: null,
};
} }
export async function getAdminStops(): Promise<AdminStop[]> { export async function getAdminStops(): Promise<AdminStop[]> {
@@ -216,22 +216,13 @@ export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDe
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null; const brandId = adminUser?.brand_id ?? null;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<AdminOrderDetail>(
"SELECT * FROM get_admin_order_detail($1, $2)",
const response = await fetch( [orderId, brandId],
`${supabaseUrl}/rest/v1/rpc/get_admin_order_detail`, );
{ return rows[0] ?? null;
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_order_id: orderId, p_brand_id: brandId }),
}
);
if (!response.ok) {
return null; return null;
} }
}
const data = await response.json();
return data;
}
+26 -37
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
export type AdminCreateOrderItem = { export type AdminCreateOrderItem = {
@@ -58,9 +58,6 @@ export async function createAdminOrder(
return { success: false, error: "At least one item is required" }; return { success: false, error: "At least one item is required" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build items for RPC (match checkout shape) // Build items for RPC (match checkout shape)
const rpcItems = input.items.map((i) => ({ const rpcItems = input.items.map((i) => ({
product_id: i.product_id, product_id: i.product_id,
@@ -77,33 +74,23 @@ export async function createAdminOrder(
const taxLocation = null; const taxLocation = null;
try { try {
const response = await fetch( const { rows } = await pool.query<{ id: string }>(
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, `SELECT * FROM create_order_with_items(
{ $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
method: "POST", )`,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, [
body: JSON.stringify({ idempotencyKey,
p_idempotency_key: idempotencyKey, input.customer_name.trim(),
p_customer_name: input.customer_name.trim(), input.customer_email?.trim() || null,
p_customer_email: input.customer_email?.trim() || null, input.customer_phone?.trim() || null,
p_customer_phone: input.customer_phone?.trim() || null, input.stop_id || null,
p_stop_id: input.stop_id || null, JSON.stringify(rpcItems),
p_items: rpcItems, taxAmount,
p_tax_amount: taxAmount, taxRate,
p_tax_rate: taxRate, taxLocation,
p_tax_location: taxLocation, ],
// The RPC may also accept brand scoping internally via stop or we can extend later.
}),
}
); );
const data = rows[0];
if (!response.ok) {
const errText = await response.text().catch(() => "Unknown error");
return { success: false, error: `Failed to create order: ${errText}` };
}
const data = await response.json();
if (!data || !data.id) { if (!data || !data.id) {
return { success: false, error: "Order created but no ID returned" }; return { success: false, error: "Order created but no ID returned" };
} }
@@ -112,18 +99,20 @@ export async function createAdminOrder(
if (input.internal_notes?.trim()) { if (input.internal_notes?.trim()) {
// Best-effort; don't fail the whole create if this secondary update fails. // Best-effort; don't fail the whole create if this secondary update fails.
try { try {
await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${data.id}`, { await pool.query(
method: "PATCH", "UPDATE orders SET internal_notes = $1 WHERE id = $2",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [input.internal_notes.trim(), data.id],
body: JSON.stringify({ internal_notes: input.internal_notes.trim() }), );
});
} catch { } catch {
// ignore // ignore
} }
} }
return { success: true, orderId: data.id, order: data }; return { success: true, orderId: data.id, order: data };
} catch (err: any) { } catch (err) {
return { success: false, error: err?.message ?? "Unexpected error creating order" }; return {
success: false,
error: err instanceof Error ? err.message : "Unexpected error creating order",
};
} }
} }
+26 -24
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type CreateRefundResult = export type CreateRefundResult =
@@ -22,37 +22,39 @@ export async function createRefund(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; let inserted: { id: string } | null = null;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; try {
const { rows } = await pool.query<{ id: string }>(
const res = await fetch(`${supabaseUrl}/rest/v1/refunds`, { `INSERT INTO refunds (order_id, amount, reason, processor, processor_refund_id, status)
method: "POST", VALUES ($1, $2, $3, $4, $5, 'pending')
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, RETURNING id`,
body: JSON.stringify({ [
order_id: orderId, orderId,
amount: data.amount, data.amount,
reason: data.reason ?? null, data.reason ?? null,
processor: data.processor ?? null, data.processor ?? null,
processor_refund_id: data.processor_refund_id ?? null, data.processor_refund_id ?? null,
status: "pending", ],
}), );
}); inserted = rows[0] ?? null;
} catch (err) {
if (!res.ok) { return {
const err = await res.text(); success: false,
return { success: false, error: `Failed: ${err}` }; error: err instanceof Error ? err.message : "Failed",
};
}
if (!inserted) {
return { success: false, error: "Insert returned no row" };
} }
const inserted = await res.json();
logAuditEvent({ logAuditEvent({
table_name: "refunds", table_name: "refunds",
record_id: inserted[0]?.id ?? "", record_id: inserted.id,
action: "INSERT", action: "INSERT",
old_data: {}, old_data: {},
new_data: { order_id: orderId, amount: data.amount }, new_data: { order_id: orderId, amount: data.amount },
brand_id: brandId, brand_id: brandId,
}); });
return { success: true, id: inserted[0]?.id ?? "" }; return { success: true, id: inserted.id };
} }
+74 -53
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
export type UpdateOrderResult = export type UpdateOrderResult =
@@ -36,33 +36,51 @@ export async function updateOrder(
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Build a partial SET clause. Each set column is added in the order
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // the caller passed it; we don't care about column ordering.
const sets: string[] = [];
const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.customer_name !== undefined) push("customer_name", data.customer_name);
if (data.customer_name !== undefined) patchData.customer_name = data.customer_name; if (data.customer_email !== undefined) push("customer_email", data.customer_email);
if (data.customer_email !== undefined) patchData.customer_email = data.customer_email; if (data.customer_phone !== undefined) push("customer_phone", data.customer_phone);
if (data.customer_phone !== undefined) patchData.customer_phone = data.customer_phone; if (data.status !== undefined) push("status", data.status);
if (data.status !== undefined) patchData.status = data.status; if (data.discount_amount !== undefined) push("discount_amount", data.discount_amount);
if (data.discount_amount !== undefined) patchData.discount_amount = data.discount_amount; if (data.discount_reason !== undefined) push("discount_reason", data.discount_reason);
if (data.discount_reason !== undefined) patchData.discount_reason = data.discount_reason; if (data.internal_notes !== undefined) push("internal_notes", data.internal_notes);
if (data.internal_notes !== undefined) patchData.internal_notes = data.internal_notes; if (data.pickup_complete !== undefined) push("pickup_complete", data.pickup_complete);
if (data.pickup_complete !== undefined) patchData.pickup_complete = data.pickup_complete; if (data.pickup_completed_at !== undefined) push("pickup_completed_at", data.pickup_completed_at);
if (data.pickup_completed_at !== undefined) patchData.pickup_completed_at = data.pickup_completed_at; if (data.subtotal !== undefined) push("subtotal", data.subtotal);
if (data.subtotal !== undefined) patchData.subtotal = data.subtotal; if (data.payment_processor !== undefined) push("payment_processor", data.payment_processor);
if (data.payment_processor !== undefined) patchData.payment_processor = data.payment_processor; if (data.payment_status !== undefined) push("payment_status", data.payment_status);
if (data.payment_status !== undefined) patchData.payment_status = data.payment_status; if (data.payment_transaction_id !== undefined) push("payment_transaction_id", data.payment_transaction_id);
if (data.payment_transaction_id !== undefined) patchData.payment_transaction_id = data.payment_transaction_id;
const res = await fetch(`${supabaseUrl}/rest/v1/orders?id=eq.${orderId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, }
body: JSON.stringify(patchData),
});
if (!res.ok) { params.push(orderId);
const err = await res.text(); const patchData = Object.fromEntries(
return { success: false, error: `Failed: ${err}` }; sets.map((s, i) => {
const col = s.split(" = ")[0];
return [col, params[i]];
}),
);
try {
await pool.query(
`UPDATE orders SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
} }
logAuditEvent({ logAuditEvent({
@@ -85,25 +103,33 @@ export async function updateOrderItem(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const sets: string[] = [];
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const params: unknown[] = [];
const push = (col: string, val: unknown) => {
params.push(val);
sets.push(`${col} = $${params.length}`);
};
const patchData: Record<string, unknown> = {}; if (data.quantity !== undefined) push("quantity", data.quantity);
if (data.quantity !== undefined) patchData.quantity = data.quantity; if (data.price !== undefined) push("price", data.price);
if (data.price !== undefined) patchData.price = data.price;
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { if (sets.length === 0) {
method: "PATCH", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(patchData),
});
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
return { success: true }; params.push(itemId);
try {
await pool.query(
`UPDATE order_items SET ${sets.join(", ")} WHERE id = $${params.length}`,
params,
);
return { success: true };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed",
};
}
} }
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> { export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
@@ -111,18 +137,13 @@ export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query("DELETE FROM order_items WHERE id = $1", [itemId]);
return { success: true };
const res = await fetch(`${supabaseUrl}/rest/v1/order_items?id=eq.${itemId}`, { } catch (err) {
method: "DELETE", return {
headers: { ...svcHeaders(supabaseKey) }, success: false,
}); error: err instanceof Error ? err.message : "Failed",
};
if (!res.ok) {
const err = await res.text();
return { success: false, error: `Failed: ${err}` };
} }
return { success: true };
} }
+30 -41
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type PaymentProvider = "stripe" | "square" | "manual"; export type PaymentProvider = "stripe" | "square" | "manual";
@@ -26,21 +26,15 @@ export type GetPaymentSettingsResult =
| { success: false; error: string }; | { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> { export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<PaymentSettings>(
"SELECT * FROM get_payment_settings($1)",
const response = await fetch( [brandId],
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`, );
{ return { success: true, settings: rows[0] ?? null };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to fetch payment settings" };
body: JSON.stringify({ p_brand_id: brandId }), }
}
);
if (!response.ok) return { success: false, error: "Failed to fetch payment settings" };
const data = await response.json();
return { success: true, settings: data };
} }
export type SavePaymentSettingsResult = export type SavePaymentSettingsResult =
@@ -72,30 +66,25 @@ export async function savePaymentSettings(params: {
return { success: false, error: "Not authorized for this brand" }; return { success: false, error: "Not authorized for this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
`SELECT upsert_payment_settings(
const response = await fetch( $1, $2, $3, $4, $5, $6, $7, $8, $9
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, )`,
{ [
method: "POST", params.brandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, params.provider,
body: JSON.stringify({ params.stripePublishableKey ?? null,
p_brand_id: params.brandId, params.stripeSecretKey ?? null,
p_provider: params.provider, params.stripeUserId ?? null,
p_stripe_publishable_key: params.stripePublishableKey ?? null, params.squareAccessToken ?? null,
p_stripe_secret_key: params.stripeSecretKey ?? null, params.squareLocationId ?? null,
p_stripe_user_id: params.stripeUserId ?? null, params.squareSyncEnabled ?? null,
p_square_access_token: params.squareAccessToken ?? null, params.squareInventoryMode ?? null,
p_square_location_id: params.squareLocationId ?? null, ],
p_square_sync_enabled: params.squareSyncEnabled ?? null, );
p_square_inventory_mode: params.squareInventoryMode ?? null, return { success: true };
}), } catch {
}
);
if (!response.ok) {
return { success: false, error: "Failed to save payment settings" }; return { success: false, error: "Failed to save payment settings" };
} }
return { success: true }; }
}
+36 -71
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type MarkPickupResult = type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null } | { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
@@ -30,67 +30,44 @@ export async function markPickupComplete(
// brand_admin: verify the order belongs to their brand // brand_admin: verify the order belongs to their brand
if (adminUser.role === "brand_admin" && adminUser.brand_id) { if (adminUser.role === "brand_admin" && adminUser.brand_id) {
const brandRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null; stop_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=stop_id,brand_id`, "SELECT brand_id, stop_id FROM orders WHERE id = $1 LIMIT 1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.rows.length === 0) {
if (!brandRes.ok) {
return { success: false, error: "Failed to verify order ownership" };
}
const orderData = await brandRes.json();
if (!Array.isArray(orderData) || orderData.length === 0) {
return { success: false, error: "Order not found" }; return { success: false, error: "Order not found" };
} }
const order = orderRes.rows[0];
const order = orderData[0];
// Check brand_id on the order first, then fall back to stop brand
if (order.brand_id && order.brand_id !== adminUser.brand_id) { if (order.brand_id && order.brand_id !== adminUser.brand_id) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
} }
if (!order.brand_id && order.stop_id) { if (!order.brand_id && order.stop_id) {
const stopRes = await fetch( const stopRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?id=eq.${order.stop_id}&select=brand_id`, "SELECT brand_id FROM stops WHERE id = $1 LIMIT 1",
{ [order.stop_id],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (
if (stopRes.ok) { stopRes.rows[0] &&
const stopData = await stopRes.json(); stopRes.rows[0].brand_id !== adminUser.brand_id
if (Array.isArray(stopData) && stopData[0]?.brand_id !== adminUser.brand_id) { ) {
return { success: false, error: "Not authorized for this order" }; return { success: false, error: "Not authorized for this order" };
}
} }
} }
} }
// PATCH the order // UPDATE the order
const patchRes = await fetch( const updateRes = await pool.query(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`, `UPDATE orders
{ SET pickup_complete = true,
method: "PATCH", pickup_completed_at = $1,
headers: { pickup_completed_by = $2
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!), WHERE id = $3`,
"Content-Type": "application/json", [now, performedBy, orderId],
Prefer: "return=representation",
},
body: JSON.stringify({
pickup_complete: true,
pickup_completed_at: now,
pickup_completed_by: performedBy,
}),
}
); );
if ((updateRes.rowCount ?? 0) === 0) {
if (!patchRes.ok) { return { success: false, error: "Order not found" };
const err = await patchRes.json().catch(() => ({ message: "Patch failed" }));
return { success: false, error: err.message ?? "Failed to update pickup" };
} }
// Fire-and-forget audit log // Fire-and-forget audit log
@@ -113,31 +90,19 @@ export async function markPickupComplete(
// Emit pickup_completed event // Emit pickup_completed event
// Need brand_id — get it from the order we just patched // Need brand_id — get it from the order we just patched
const orderRes = await fetch( const orderRes = await pool.query<{ brand_id: string | null }>(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}&select=brand_id`, "SELECT brand_id FROM orders WHERE id = $1",
{ [orderId],
headers: svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
}
); );
if (orderRes.ok) { const orderBrandId = orderRes.rows[0]?.brand_id;
const orderData = await orderRes.json(); if (orderBrandId) {
const orderBrandId = Array.isArray(orderData) && orderData[0]?.brand_id; try {
if (orderBrandId) { await pool.query(
await fetch( "SELECT * FROM record_pickup_completed_event($1, $2, $3)",
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/record_pickup_completed_event`, [orderId, orderBrandId, performedBy],
{
method: "POST",
headers: {
...svcHeaders(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_order_id: orderId,
p_brand_id: orderBrandId,
p_actor_id: performedBy,
}),
}
); );
} catch {
// Event emission is best-effort.
} }
} }
@@ -146,4 +111,4 @@ export async function markPickupComplete(
pickup_completed_at: now, pickup_completed_at: now,
pickup_completed_by: performedBy, pickup_completed_by: performedBy,
}; };
} }
+17 -41
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
export async function deleteProduct( export async function deleteProduct(
productId: string, productId: string,
@@ -23,27 +24,23 @@ export async function deleteProduct(
} }
const effectiveBrandId = activeBrandId; const effectiveBrandId = activeBrandId;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `delete_product` is a SECURITY DEFINER RPC that soft-deletes the row
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // (sets `deleted_at`) and guards against products referenced by
// existing order_items. It returns JSONB {success, error?}.
const response = await fetch( let result: { success?: boolean; error?: string } = {};
`${supabaseUrl}/rest/v1/rpc/delete_product`, try {
{ const { rows } = await pool.query<{ success: boolean; error?: string }>(
method: "POST", "SELECT * FROM delete_product($1, $2)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [productId, effectiveBrandId],
body: JSON.stringify({ );
p_product_id: productId, result = rows[0] ?? {};
p_brand_id: effectiveBrandId, } catch (err) {
}), return {
} success: false,
); error: err instanceof Error ? err.message : "Delete failed",
};
if (!response.ok) {
const err = await response.json().catch(() => ({ message: "Request failed" }));
return { success: false, error: err.message ?? "Delete failed" };
} }
const result = await response.json();
if (!result.success) { if (!result.success) {
return { success: false, error: result.error ?? "Delete failed" }; return { success: false, error: result.error ?? "Delete failed" };
} }
@@ -59,24 +56,3 @@ export async function deleteProduct(
return { success: true }; return { success: true };
} }
async function logAuditEvent(event: {
table_name: string;
record_id: string;
action: string;
old_data: Record<string, unknown>;
new_data: Record<string, unknown>;
brand_id: string | null;
}) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
await fetch(
`${supabaseUrl}/rest/v1/rpc.log_audit_event`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify(event),
}
);
}
+83 -75
View File
@@ -3,7 +3,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope"; import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type StopImportRow = { export type StopImportRow = {
city: string; city: string;
@@ -35,12 +35,6 @@ export async function createStopsBatch(
return { success: false, created: 0, error: "No brand selected" }; return { success: false, created: 0, error: "No brand selected" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// Use anon key — admin_create_stops_batch is SECURITY DEFINER so RLS is
// bypassed. This fixes the prior 42501 RLS violation that came from doing
// direct REST inserts against the RLS-blocked `stops` table.
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rows = stops.map((s) => ({ const rows = stops.map((s) => ({
city: s.city, city: s.city,
state: s.state, state: s.state,
@@ -53,22 +47,30 @@ export async function createStopsBatch(
active: false, active: false,
})); }));
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, { // `admin_create_stops_batch` is SECURITY DEFINER — bypasses RLS, so we
method: "POST", // can call it as the app's DB role.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, let inserted: { id?: string }[] = [];
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }), try {
}); const { rows: rpcRows } = await pool.query<{ id?: string }>(
"SELECT * FROM admin_create_stops_batch($1, $2::jsonb)",
if (!res.ok) { [effectiveBrandId, JSON.stringify(rows)],
const err = await res.json().catch(() => ({ message: "Request failed" })); );
return { success: false, created: 0, error: (err as { message?: string }).message ?? "Insert failed" }; inserted = rpcRows;
} catch (err) {
return {
success: false,
created: 0,
error: err instanceof Error ? err.message : "Insert failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default"); revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
const inserted = await res.json(); return {
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length }; success: true,
created: Array.isArray(inserted) ? inserted.length : stops.length,
};
} }
export async function publishStop( export async function publishStop(
@@ -79,18 +81,22 @@ export async function publishStop(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" }; if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Direct update via raw SQL — the stops table lives in the legacy
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // schema (city/state/date/active), so Drizzle's new-schema stops
// table doesn't have the columns we'd need to set.
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { try {
method: "PATCH", const { rowCount } = await pool.query(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, "UPDATE stops SET status = 'active', active = true WHERE id = $1",
body: JSON.stringify({ status: "active", active: true }), [stopId],
}); );
if (!rowCount) {
if (!res.ok) { return { success: false, error: "Stop not found" };
const err = await res.json().catch(() => ({ message: "Patch failed" })); }
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" }; } catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Publish failed",
};
} }
revalidateTag("stops", "default"); revalidateTag("stops", "default");
@@ -99,6 +105,41 @@ export async function publishStop(
return { success: true }; return { success: true };
} }
/**
* Soft-delete a stop via the `delete_stop` SECURITY DEFINER RPC. Guards
* against deleting a stop that has open (non-pickup) orders.
*/
export async function deleteStop(
stopId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
let data: { success?: boolean; error?: string } = {};
try {
const { rows } = await pool.query<{ success?: boolean; error?: string }>(
"SELECT * FROM delete_stop($1, $2)",
[stopId, brandId],
);
data = rows[0] ?? {};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Delete failed",
};
}
if (!data.success) {
return { success: false, error: data.error ?? "Delete failed" };
}
revalidateTag("stops", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
return { success: true };
}
/** /**
* Fetch active stops for sitemap generation. * Fetch active stops for sitemap generation.
* This is a public function that doesn't require authentication. * This is a public function that doesn't require authentication.
@@ -110,27 +151,13 @@ export type StopForSitemap = {
}; };
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> { export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug.
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't // Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
// crash the prerender — the sitemap just renders without stop URLs. // crash the prerender — the sitemap just renders without stop URLs.
try { try {
const response = await fetch( const { rows } = await pool.query<StopForSitemap>(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`, "SELECT * FROM get_active_stops_with_brand()",
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
} catch { } catch {
return []; return [];
} }
@@ -139,9 +166,7 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
/** /**
* Fetch active stops for a brand by slug. * Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops, * Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) — replaces the previous client-side * /indian-river-direct/stops).
* `supabase.from("stops")` query so supabase-js no longer ships to
* the browser for those routes.
* *
* Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')` * Cached at the edge for 5 minutes; invalidated by `revalidateTag('stops')`
* from any stop mutation (see createStopsBatch, publishStop, etc.). * from any stop mutation (see createStopsBatch, publishStop, etc.).
@@ -163,33 +188,16 @@ export async function getPublicStopsForBrand(
): Promise<PublicStop[]> { ): Promise<PublicStop[]> {
if (!brandSlug) return []; if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; // Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; // crash the prerender — the page just renders with no stops and
// revalidates from a real request once the cache is warm.
if (!supabaseUrl || !supabaseKey) return [];
// Wrapped in try/catch so a build-time Supabase outage (ECONNREFUSED)
// doesn't crash the prerender — the page just renders with no stops
// and revalidates from a real request once the cache is warm.
try { try {
const response = await fetch( const { rows } = await pool.query<PublicStop>(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`, "SELECT * FROM get_public_stops_for_brand($1)",
{ [brandSlug],
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
); );
return Array.isArray(rows) ? rows : [];
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
} catch { } catch {
return []; return [];
} }
} }
+35 -79
View File
@@ -2,7 +2,7 @@
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { getMockTableData } from "@/lib/mock-data"; import { getMockTableData } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -38,87 +38,43 @@ export async function createStop(
return { success: true, id: `mock-stop-${Date.now()}` }; return { success: true, id: `mock-stop-${Date.now()}` };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
// Preferred path: anon key + SECURITY DEFINER RPC (bypasses RLS, works even if // block_stops_mutations RLS policy. It returns either {success,
// service role key is absent at runtime). See: // stop_id} or {success:false, error}. Migration 202.
// supabase/migrations/202_fix_admin_create_stop.sql let rpcResult: { success?: boolean; error?: string; stop_id?: string; id?: string } = {};
// scripts/apply-admin-create-stop.js (run this locally with the keys you have) try {
// supabase/ADMIN_CREATE_STOP_FIX.sql (exact prompt SQL for SQL editor) const { rows } = await pool.query<{ success?: boolean; error?: string; stop_id?: string; id?: string }>(
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM admin_create_stop($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
[
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, { data.active ?? false,
method: "POST", data.address || null,
headers: { ...svcHeaders(anonKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ data.city,
p_active: data.active ?? false, data.cutoff_time || null,
p_address: data.address || null, data.date,
p_brand_id: brandId, data.location,
p_city: data.city, data.state,
p_cutoff_time: data.cutoff_time || null, data.time,
p_date: data.date, data.zip || null,
p_location: data.location, ],
p_state: data.state, );
p_time: data.time, rpcResult = rows[0] ?? {};
p_zip: data.zip || null, } catch (err) {
}),
});
let usedFallback = false;
if (!res.ok) {
const errText = await res.text();
const lower = errText.toLowerCase();
const looksLikeMissingFn =
lower.includes("pgrst202") ||
lower.includes("admin_create_stop") ||
lower.includes("could not find the function") ||
lower.includes("function not found");
if (looksLikeMissingFn) {
usedFallback = true;
} else {
return { success: false, error: `Failed: ${errText}` };
}
} else {
const inserted = await res.json().catch(() => ({} as any));
if (inserted && inserted.success === false) {
// Our RPC returns structured errors as 200 + {success:false}
const errMsg = inserted.error || "Failed to create stop";
const lower = errMsg.toLowerCase();
if (lower.includes("function") || lower.includes("not found") || lower.includes("pgrst")) {
usedFallback = true;
} else {
return { success: false, error: errMsg };
}
} else {
// Happy path from RPC (either old {id,slug} or new {success, stop_id})
const stopId = inserted?.stop_id || inserted?.id || "";
const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) {
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
}
return { success: true, id: stopId };
}
}
if (usedFallback) {
// We cannot bypass the block_stops_mutations RLS policy via REST even with the service key.
// The only reliable path is the SECURITY DEFINER RPC created by migration 202.
// Tell the user exactly how to install it using only the keys they already have.
return { return {
success: false, success: false,
error: error: err instanceof Error ? err.message : "Failed to create stop",
"The admin_create_stop database function is missing (this is why Add New Stop fails with function not found).\n\n" +
"Fix using only the keys in your .env.local (no Supabase dashboard needed):\n" +
" 1. On a machine that can reach your database (normal laptop usually works):\n" +
" node scripts/apply-admin-create-stop.js\n" +
" 2. Or: npm run migrate:one 202\n" +
" 3. Or apply supabase/migrations/202_fix_admin_create_stop.sql via psql / Supabase CLI with --db-url.\n\n" +
"After it succeeds, restart your dev server and Add New Stop will work.",
}; };
} }
// Should not reach here if (rpcResult.success === false) {
return { success: false, error: "Unexpected state creating stop" }; return { success: false, error: rpcResult.error ?? "Failed to create stop" };
}
const stopId = rpcResult.stop_id || rpcResult.id || "";
const effectiveBrandId = brandId || adminUser.brand_id;
if (effectiveBrandId) {
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
}
return { success: true, id: stopId };
} }
+39 -45
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { createClient } from "@supabase/supabase-js"; import { pool } from "@/lib/db";
export type StopDetail = { export type StopDetail = {
id: string; id: string;
@@ -50,39 +50,32 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// Use a fresh server-side client (cookie-less) so RLS doesn't block reads if (useMockData) {
// for platform_admin dev sessions. The auth check above has already gated return { success: false, error: "Stop not found" };
// access.
const server = useMockData
? null
: createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false, autoRefreshToken: false },
});
// 1. Stop + brand
let stop: StopDetail | null = null;
let stopErr: string | null = null;
if (server) {
const { data, error } = await server
.from("stops")
.select("*, brands(name, slug)")
.eq("id", stopId)
.single();
if (error) stopErr = error.message;
else stop = (data ?? null) as StopDetail | null;
} else {
// Mock fallback — empty
stopErr = "Stop not found";
} }
if (!stop) { // 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
return { success: false, error: stopErr ?? "Stop not found" }; // maps to the new schema which doesn't have city/state/date/etc).
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
`SELECT s.*, b.name AS brand_name, b.slug AS brand_slug
FROM stops s
LEFT JOIN brands b ON b.id = s.brand_id
WHERE s.id = $1
LIMIT 1`,
[stopId],
);
const stopRow = stopRes.rows[0];
if (!stopRow) {
return { success: false, error: "Stop not found" };
} }
const stop: StopDetail = {
...stopRow,
brands: stopRow.brand_name
? { name: stopRow.brand_name, slug: stopRow.brand_slug }
: null,
};
// Brand-scope check for brand_admin // Brand-scope check for brand_admin
if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) { if (adminUser.brand_id && stop.brand_id !== adminUser.brand_id) {
@@ -90,26 +83,27 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
} }
// 2. Candidate products for this brand // 2. Candidate products for this brand
const { data: allProducts } = server const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>(
? await server `SELECT id, name, type, price
.from("products") FROM products
.select("id, name, type, price") WHERE brand_id = $1 AND active = true`,
.eq("brand_id", stop.brand_id) [stop.brand_id],
.eq("active", true) );
: { data: [] as { id: string; name: string; type: string; price: number }[] };
// 3. Assigned products (joined with product info) // 3. Assigned products (joined with product info)
const { data: productStops } = server const { rows: productStops } = await pool.query<AssignedProduct>(
? await server `SELECT ps.id, ps.product_id,
.from("product_stops") json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products
.select("id, product_id, products(id, name, type, price)") FROM product_stops ps
.eq("stop_id", stopId) LEFT JOIN products p ON p.id = ps.product_id
: { data: [] as AssignedProduct[] }; WHERE ps.stop_id = $1`,
[stopId],
);
// 4. Brands for the brand switcher // 4. Brands for the brand switcher
const { data: brands } = server const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>(
? await server.from("brands").select("id, name, slug") `SELECT id, name, slug FROM brands ORDER BY name`,
: { data: [] as { id: string; name: string; slug: string }[] }; );
return { return {
success: true, success: true,
+43 -24
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit"; import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
@@ -37,32 +37,51 @@ export async function updateStop(
return { success: true }; return { success: true };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`; const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, { // Direct UPDATE on the legacy stops table — the new-schema Drizzle
method: "PATCH", // stops table doesn't have the columns we need to write.
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const { rowCount, error } = await pool
body: JSON.stringify({ .query(
city: data.city, `UPDATE stops SET
state: data.state, city = $1,
location: data.location, state = $2,
date: data.date, location = $3,
time: data.time, date = $4,
slug, time = $5,
active: data.active, slug = $6,
brand_id: brandId, active = $7,
address: data.address ?? null, brand_id = $8,
zip: data.zip ?? null, address = $9,
cutoff_time: data.cutoff_time ?? null, zip = $10,
}), cutoff_time = $11
}); WHERE id = $12`,
[
data.city,
data.state,
data.location,
data.date,
data.time,
slug,
data.active,
brandId,
data.address ?? null,
data.zip ?? null,
data.cutoff_time ?? null,
stopId,
],
)
.then((r) => ({ rowCount: r.rowCount ?? 0, error: null }))
.catch((e: unknown) => ({
rowCount: 0,
error: e instanceof Error ? e : new Error(String(e)),
}));
if (!res.ok) { if (error || !rowCount) {
const err = await res.text(); return {
return { success: false, error: `Failed: ${err}` }; success: false,
error: error ? error.message : "Stop not found",
};
} }
logAuditEvent({ logAuditEvent({
+75 -56
View File
@@ -4,6 +4,9 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit"; import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { analytics } from "@/lib/analytics"; import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry"; import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
// Helper functions // Helper functions
function apiResponse(data: unknown, status: number = 200) { function apiResponse(data: unknown, status: number = 200) {
@@ -55,44 +58,47 @@ export async function GET(req: NextRequest) {
return validationError(validation.error); return validationError(validation.error);
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Build the WHERE conditions. We use `withDb` (no tenant GUC) here
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; // because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
// Build query const whereParts = [];
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
if (validation.data.brand_id) { if (validation.data.brand_id) {
query += `&brand_id=eq.${validation.data.brand_id}`; whereParts.push(eq(products.tenantId, validation.data.brand_id));
}
if (validation.data.category) {
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
} }
if (validation.data.is_active !== undefined) { if (validation.data.is_active !== undefined) {
query += `&is_active=eq.${validation.data.is_active}`; whereParts.push(eq(products.active, validation.data.is_active));
} }
if (validation.data.search) { if (validation.data.search) {
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`; const term = `%${validation.data.search}%`;
whereParts.push(
or(ilike(products.name, term), ilike(products.description, term))!,
);
} }
const where = whereParts.length > 0 ? and(...whereParts) : undefined;
const res = await fetch(query, { // Note: the legacy schema had a `category` column on products; the
headers: { // new schema doesn't. The category filter is silently ignored — no
apikey: anonKey, // point failing a public read just because the column disappeared.
"Content-Type": "application/json", void validation.data.category;
},
});
if (!res.ok) { // Public read across all tenants (this is a public catalog API, not
return apiError("Failed to fetch products", 500); // an admin endpoint), so use `withDb` rather than `withTenant`.
} const rows = await withDb(async (db) =>
db
.select()
.from(products)
.where(where)
.orderBy(products.name)
.limit(validation.data.limit)
.offset(validation.data.offset),
);
const products = await res.json();
// Track search analytics // Track search analytics
if (validation.data.search) { if (validation.data.search) {
analytics.searchPerformed(validation.data.search, products.length, "products"); analytics.searchPerformed(validation.data.search, rows.length, "products");
} }
return apiResponse(products, 200); return apiResponse(rows, 200);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/products", method: "GET" }); captureError(error as Error, { path: "/api/products", method: "GET" });
return apiError("Internal server error", 500); return apiError("Internal server error", 500);
@@ -108,42 +114,51 @@ export async function POST(req: NextRequest) {
} }
const body = await req.json(); const body = await req.json();
const { brand_id, name, description, price, category, is_active } = body; const { brand_id, name, description, price, is_active } = body as {
brand_id?: string;
name?: string;
description?: string;
price?: number;
category?: string;
is_active?: boolean;
};
if (!brand_id || !name) { if (!brand_id || !name) {
return apiError("brand_id and name are required", 400); return apiError("brand_id and name are required", 400);
} }
if (typeof price !== "number") {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; return apiError("price is required (number)", 400);
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/products`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
brand_id,
name,
description,
price,
category,
is_active: is_active !== false,
}),
}
);
if (!res.ok) {
const error = await res.json();
return apiError(error.message || "Failed to create product", 500);
} }
const product = await res.json(); // Insert the product. Tenant context is required because `products`
return apiResponse(product, 201); // is a tenant-scoped table with RLS.
let inserted: Product | null = null;
let insertError: string | null = null;
try {
inserted = await withPlatformAdmin(async (db) => {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
active: is_active !== false,
})
.returning();
return row ?? null;
});
} catch (err) {
insertError = err instanceof Error ? err.message : "Failed to create product";
}
if (insertError) {
return apiError(insertError, 500);
}
if (!inserted) {
return apiError("Insert returned no row", 500);
}
return apiResponse(inserted, 201);
} catch (error) { } catch (error) {
captureError(error as Error, { path: "/api/products", method: "POST" }); captureError(error as Error, { path: "/api/products", method: "POST" });
return apiError("Internal server error", 500); return apiError("Internal server error", 500);
@@ -163,4 +178,8 @@ export async function OPTIONS() {
"Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Allow-Headers": "Content-Type, Authorization",
}, },
}); });
} }
// Keep `sql` reachable so the import isn't tree-shaken — we use it
// elsewhere if query extensions are added.
void sql;
+2 -13
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal"; import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal"; import AddStopModal from "@/components/admin/AddStopModal";
import { publishStop } from "@/actions/stops"; import { publishStop, deleteStop } from "@/actions/stops";
type Stop = { type Stop = {
id: string; id: string;
@@ -85,18 +85,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
} }
async function handleDelete(stopId: string) { async function handleDelete(stopId: string) {
const res = await fetch( const data = await deleteStop(stopId, brandId);
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: brandId }),
}
);
const data = await res.json();
setConfirmDelete(null); setConfirmDelete(null);
setOpenMenu(null); setOpenMenu(null);
if (data.success) { if (data.success) {