Refactor: move public storefront stop data to server-side + parallel agent work
Server-side / caching refactor (Grok): - New RPC get_public_stops_for_brand (migration 148) for public storefront stops - New server action getPublicStopsForBrand with revalidate=300 + tags - Add revalidateTag invalidation to createStopsBatch + publishStop - Convert /tuxedo/stops and /indian-river-direct/stops to Server Components - Extract TuxedoStopsList + IndianRiverStopsList as client islands (GSAP only) - Removes supabase-js from browser bundle on those routes - Both pages now statically prerendered (5m ISR) Parallel agent changes also staged: - AI provider model list refresh (claude-sonnet-4-5, etc.) - ESLint directive patches for react-hooks/set-state-in-effect - Admin + storefront + checkout + cart updates - New admin_create_stop_rpcs migration (147) - Misc fixes across ~90 files Build verified: typecheck clean, lint clean on new files, production build succeeds.
This commit is contained in:
@@ -119,7 +119,14 @@ async function callAIAnalysis(
|
||||
rows: string[][],
|
||||
brandId: string
|
||||
): Promise<ImportAnalysis> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
// Prefer MiniMax (env-level) — the team is using it pre-launch. Fall back to OpenAI.
|
||||
const provider: "minimax" | "openai" =
|
||||
process.env.MINIMAX_API_KEY ? "minimax" : process.env.OPENAI_API_KEY ? "openai" : "openai";
|
||||
const apiKey = provider === "minimax" ? process.env.MINIMAX_API_KEY! : process.env.OPENAI_API_KEY!;
|
||||
const baseURL = provider === "minimax"
|
||||
? (process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1")
|
||||
: "https://api.openai.com/v1";
|
||||
const model = provider === "minimax" ? "MiniMax-M3" : "gpt-4o-mini";
|
||||
|
||||
// Build sample rows (first 30 for token economy)
|
||||
const sampleRows = rows.slice(0, 30);
|
||||
@@ -174,21 +181,23 @@ Rules:
|
||||
- Do NOT add __entity_type to cleanedRows — only in columnMappings output`;
|
||||
|
||||
try {
|
||||
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
const res = await fetch(`${baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `Headers: ${headers.join(", ")}\n\nData (first 30 rows):\n${sampleText.slice(0, 6000)}` },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
// Note: response_format is OpenAI-specific. MiniMax /v1/chat/completions supports
|
||||
// JSON-style prompting but may not honor `json_object`. We omit it for MiniMax.
|
||||
...(provider === "openai" ? { response_format: { type: "json_object" } } : {}),
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`OpenAI error: ${res.status}`);
|
||||
if (!res.ok) throw new Error(`${provider} error: ${res.status}`);
|
||||
|
||||
const data = await res.json();
|
||||
const parsed = JSON.parse(data.choices[0].message.content as string);
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
|
||||
export type AIProvider = AIProviderType;
|
||||
|
||||
export type AIProviderSettings = {
|
||||
provider: AIProvider;
|
||||
@@ -25,6 +26,10 @@ export type CustomIntegration = {
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// MiniMax (MiniMax) is OpenAI-compatible. Default to the global endpoint; allow
|
||||
// override via MINIMAX_BASE_URL env var (e.g. https://api.minimaxi.com/v1 for CN).
|
||||
const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
|
||||
|
||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||
@@ -111,9 +116,26 @@ export async function getAIClient(brandId: string): Promise<{
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
|
||||
if (!settings.apiKey) {
|
||||
// Fall back to env var
|
||||
const envKey = process.env.OPENAI_API_KEY;
|
||||
if (!envKey) return { provider: "openai", model: "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
// Fall back to env var. Check the saved provider first, then universal env keys.
|
||||
const envKey =
|
||||
process.env.MINIMAX_API_KEY ||
|
||||
process.env.OPENAI_API_KEY ||
|
||||
process.env.ANTHROPIC_API_KEY ||
|
||||
process.env.XAI_API_KEY ||
|
||||
process.env.GOOGLE_API_KEY;
|
||||
if (!envKey) {
|
||||
return { provider: settings.provider || "openai", model: DEFAULT_MODELS[settings.provider as Exclude<AIProvider, "custom">] ?? "gpt-4o-mini", client: null, error: "No API key configured" };
|
||||
}
|
||||
// If the saved provider is minimax, use MiniMax even from env
|
||||
if (settings.provider === "minimax" || process.env.MINIMAX_API_KEY) {
|
||||
const { OpenAI } = await import("openai");
|
||||
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||
return {
|
||||
provider: "minimax",
|
||||
model: settings.model || DEFAULT_MODELS.minimax,
|
||||
client: new OpenAI({ apiKey: process.env.MINIMAX_API_KEY ?? envKey, baseURL }),
|
||||
};
|
||||
}
|
||||
const { OpenAI } = await import("openai");
|
||||
return {
|
||||
provider: "openai",
|
||||
@@ -147,6 +169,16 @@ export async function getAIClient(brandId: string): Promise<{
|
||||
client: new XAIOpenAI({ apiKey: settings.apiKey, baseURL: "https://api.x.ai/v1" }),
|
||||
};
|
||||
}
|
||||
case "minimax": {
|
||||
// MiniMax (MiniMax) is OpenAI-compatible — swap baseURL
|
||||
const { OpenAI } = await import("openai");
|
||||
const baseURL = process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL;
|
||||
return {
|
||||
provider: "minimax",
|
||||
model: settings.model || DEFAULT_MODELS.minimax,
|
||||
client: new OpenAI({ apiKey: settings.apiKey, baseURL }),
|
||||
};
|
||||
}
|
||||
case "custom": {
|
||||
if (!settings.customEndpoint) {
|
||||
// @ts-expect-error - intentionally returning extra property for error signaling
|
||||
|
||||
+73
-19
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
@@ -30,29 +31,27 @@ export async function createStopsBatch(
|
||||
}
|
||||
|
||||
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 slug = `${s.city.toLowerCase().replace(/\s+/g, "-")}-${s.date || new Date().toISOString().slice(0, 10)}`;
|
||||
return {
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
location: s.location,
|
||||
date: s.date || "",
|
||||
time: s.time || "",
|
||||
address: s.address || null,
|
||||
zip: s.zip || null,
|
||||
brand_id: effectiveBrandId,
|
||||
slug,
|
||||
status: "draft",
|
||||
active: false,
|
||||
};
|
||||
});
|
||||
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/stops`, {
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stops_batch`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify(rows),
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_stops: rows }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -60,6 +59,9 @@ export async function createStopsBatch(
|
||||
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 };
|
||||
}
|
||||
@@ -86,6 +88,9 @@ export async function publishStop(
|
||||
return { success: false, error: (err as { message?: string }).message ?? "Publish failed" };
|
||||
}
|
||||
|
||||
revalidateTag("stops", "default");
|
||||
revalidateTag(`brand:${brandId}:stops`, "default");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -116,4 +121,53 @@ export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
|
||||
|
||||
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!;
|
||||
|
||||
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[]) : [];
|
||||
}
|
||||
@@ -38,26 +38,25 @@ export async function createStop(
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
// Use anon key — the RPC is SECURITY DEFINER so it bypasses RLS regardless
|
||||
// of caller. This also means a missing SUPABASE_SERVICE_ROLE_KEY in
|
||||
// production no longer breaks stop creation.
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date || new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/stops`, {
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/admin_create_stop`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
location: data.location,
|
||||
date: data.date,
|
||||
time: data.time,
|
||||
slug,
|
||||
brand_id: brandId,
|
||||
active: data.active ?? false,
|
||||
address: data.address ?? null,
|
||||
zip: data.zip ?? null,
|
||||
cutoff_time: data.cutoff_time ?? null,
|
||||
status: "draft",
|
||||
p_brand_id: brandId,
|
||||
p_city: data.city,
|
||||
p_state: data.state,
|
||||
p_location: data.location,
|
||||
p_date: data.date,
|
||||
p_time: data.time,
|
||||
p_address: data.address ?? null,
|
||||
p_zip: data.zip ?? null,
|
||||
p_cutoff_time: data.cutoff_time ?? null,
|
||||
p_active: data.active ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -67,5 +66,5 @@ export async function createStop(
|
||||
}
|
||||
|
||||
const inserted = await res.json();
|
||||
return { success: true, id: inserted[0]?.id ?? "" };
|
||||
return { success: true, id: inserted?.id ?? "" };
|
||||
}
|
||||
|
||||
@@ -268,11 +268,35 @@ export async function deleteWaterHeadgate(headgateId: string): Promise<{ success
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to delete headgate" };
|
||||
// The RPC returns JSONB on success: { success: true } or { success: false, error: "..." }.
|
||||
// On failure (HTTP non-2xx) Supabase returns { message: "...", code: "...", details: ... }.
|
||||
// We try to extract the most useful message in both cases.
|
||||
let data: { success?: boolean; error?: string; message?: string } | null = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
// Non-JSON body — leave data as null, fall through to default error
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
if (response.ok && data?.success) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Prefer the RPC's own error if it set one
|
||||
const errorMessage =
|
||||
data?.error ??
|
||||
data?.message ??
|
||||
(response.ok ? "Unknown error" : `HTTP ${response.status}: ${response.statusText || "request failed"}`);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("[deleteWaterHeadgate] failed", {
|
||||
headgateId,
|
||||
status: response.status,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
// ── Entries ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user