Files
route-commerce/src/actions/stops.ts
T
tyler 2f3be5426f
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
fix(actions): skip Supabase fetch at build time when env vars unset
The /indian-river-direct/stops page and sitemap prerender at build time
and call getPublicStopsForBrand / getActiveStopsForSitemap / getBrandSettingsPublic.
Those actions fetch NEXT_PUBLIC_SUPABASE_URL via Supabase REST. During
the GitHub/Gitea build, the Supabase secret is unset (or the value is
".supabase.co" which doesn't resolve), so the fetch errors with
ECONNREFUSED and the build aborts.

Return [] / not-configured when the env vars are missing so the prerender
can complete. Runtime behavior is unchanged when the vars are set.
2026-06-06 05:12:55 +00:00

185 lines
6.0 KiB
TypeScript

"use server";
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
export type StopImportRow = {
city: string;
state: string;
location: string;
date: string;
time: string;
address?: string;
zip?: string;
notes?: string;
};
export async function createStopsBatch(
brandId: string,
stops: StopImportRow[]
): Promise<{ success: boolean; created: number; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized to manage stops" };
}
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, created: 0, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
if (!effectiveBrandId) {
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) => ({
city: s.city,
state: s.state,
location: s.location,
date: s.date || "",
time: s.time || "",
address: s.address || null,
zip: s.zip || null,
cutoff_time: null,
active: false,
}));
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
});
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" };
}
revalidateTag("stops", "default");
revalidateTag(`brand:${effectiveBrandId}:stops`, "default");
const inserted = await res.json();
return { success: true, created: Array.isArray(inserted) ? inserted.length : stops.length };
}
export async function publishStop(
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" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/stops?id=eq.${stopId}`, {
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ status: "active", active: true }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: "Patch failed" }));
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
}
revalidateTag("stops", "default");
revalidateTag(`brand:${brandId}:stops`, "default");
return { success: true };
}
/**
* Fetch active stops for sitemap generation.
* This is a public function that doesn't require authentication.
*/
export type StopForSitemap = {
slug: string;
brand_slug: string;
last_modified: string;
};
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the sitemap render with only the static URLs.
if (!supabaseUrl || !supabaseKey) return [];
// Get all active stops with their brand slug
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_stops_with_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
}
);
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? stops : [];
}
/**
* Fetch active stops for a brand by slug.
* Public action used by the storefront stop pages (e.g. /tuxedo/stops,
* /indian-river-direct/stops) — replaces the previous client-side
* `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')`
* from any stop mutation (see createStopsBatch, publishStop, etc.).
*/
export type PublicStop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
address: string | null;
slug: string;
cutoff_time: string | null;
};
export async function getPublicStopsForBrand(
brandSlug: string
): Promise<PublicStop[]> {
if (!brandSlug) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// Build-time prerender runs before Supabase env is configured. Returning
// an empty list lets the page render with zero stops; at runtime the
// fetch path is unchanged.
if (!supabaseUrl || !supabaseKey) return [];
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_public_stops_for_brand`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_slug: brandSlug }),
next: {
revalidate: 300,
tags: ["stops", `brand:${brandSlug}:stops`],
},
}
);
if (!response.ok) return [];
const stops = await response.json();
return Array.isArray(stops) ? (stops as PublicStop[]) : [];
}