2f3be5426f
Deploy to route.crispygoat.com / deploy (push) Failing after 2m50s
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.
185 lines
6.0 KiB
TypeScript
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[]) : [];
|
|
} |