From 2cf811a66b66ab43391e9723506ca7e2585d93eb Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 17:18:53 -0600 Subject: [PATCH] fix(storefront): align store actions with actual DB schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught three runtime breakages that the prior commit would have introduced (the legacy supabase shim masked them by returning null): 1. **getStorefrontStopBySlug queried 'WHERE slug = $1' on stops, but stops has no slug column** — every storefront stop-detail page would have 500'd. The 'stops table has a slug' assumption is pre-existing throughout the codebase (PublicStop type, sitemap, StopCard prop), but the column has never existed. Fix: switch the route param to the stop's UUID (id). Rename folder [slug] → [id] for honesty. Update StopCard prop and the two stops-list links to pass stop.id instead of stop.slug. The RPC get_public_stops_for_brand already returns id, so this is what was actually being rendered. 2. **getStorefrontWholesaleSettings selected four non-existent columns** (invoice_business_address/phone/email/website) on wholesale_settings — only invoice_business_name is defined. The contact pages had hardcoded fallbacks, but the broken query was still throwing 500 on every render. Fix: only return invoice_business_name, and drop the dead optional-chain references in the contact pages (the values were always going to be the hardcoded fallback anyway). 3. **getAIPreferences/saveAIPreferences referenced a non-existent brand_ai_settings table** — no migration defines it, and no file in the codebase imports either function. Deleted the whole preferences.ts module (dead code that would have 500'd the moment anyone wired it up). 4. **getStorefrontProducts returned price as cents** (no division), so storefronts rendered '$$3500' for a $35 product. Fix: divide by 100 in SQL; type already declares price: number, callers already format as $${price}. Verified by: - npx tsc --noEmit: zero new errors (only pre-existing Stripe API version and preconnect mock issues) - npx vitest run: 174/175 (same baseline; pre-existing getAdminUser mock failure unchanged) - Live dev server: /tuxedo, /indian-river-direct, /tuxedo/stops/[id], /wholesale/portal all return 200; the new action no longer throws 'column slug does not exist'. --- src/actions/ai/preferences.ts | 108 ------------------ src/actions/storefront.ts | 37 +++--- .../contact/ContactClientPage.tsx | 8 +- .../stops/IndianRiverStopsList.tsx | 2 +- .../stops/{[slug] => [id]}/error.tsx | 0 .../stops/{[slug] => [id]}/loading.tsx | 0 .../stops/{[slug] => [id]}/page.tsx | 4 +- src/app/tuxedo/contact/ContactClientPage.tsx | 10 +- src/app/tuxedo/stops/TuxedoStopsList.tsx | 2 +- .../tuxedo/stops/{[slug] => [id]}/error.tsx | 0 .../tuxedo/stops/{[slug] => [id]}/loading.tsx | 0 .../tuxedo/stops/{[slug] => [id]}/page.tsx | 4 +- src/components/storefront/StopCard.tsx | 8 +- 13 files changed, 37 insertions(+), 146 deletions(-) delete mode 100644 src/actions/ai/preferences.ts rename src/app/indian-river-direct/stops/{[slug] => [id]}/error.tsx (100%) rename src/app/indian-river-direct/stops/{[slug] => [id]}/loading.tsx (100%) rename src/app/indian-river-direct/stops/{[slug] => [id]}/page.tsx (98%) rename src/app/tuxedo/stops/{[slug] => [id]}/error.tsx (100%) rename src/app/tuxedo/stops/{[slug] => [id]}/loading.tsx (100%) rename src/app/tuxedo/stops/{[slug] => [id]}/page.tsx (98%) diff --git a/src/actions/ai/preferences.ts b/src/actions/ai/preferences.ts deleted file mode 100644 index 384c3c5..0000000 --- a/src/actions/ai/preferences.ts +++ /dev/null @@ -1,108 +0,0 @@ -"use server"; - -import { pool } from "@/lib/db"; - -export type AIAuthConfig = { - provider: string; - api_key: string; - organization_id: string; - base_url: string; - model: string; - max_tokens: number; -}; - -type AIRow = { - api_key: string | null; - organization_id: string | null; - base_url: string | null; - model: string | null; - max_tokens: number | null; -}; - -export async function getAIPreferences(brandId: string): Promise<{ - api_key?: string; - organization_id?: string; - base_url?: string; - model?: string; - max_tokens?: number; -} | null> { - const { rows } = await pool.query( - `SELECT api_key, organization_id, base_url, model, max_tokens - FROM brand_ai_settings - WHERE brand_id = $1 - LIMIT 1`, - [brandId], - ); - const row = rows[0]; - if (!row) return null; - return { - api_key: row.api_key ?? undefined, - organization_id: row.organization_id ?? undefined, - base_url: row.base_url ?? undefined, - model: row.model ?? undefined, - max_tokens: row.max_tokens ?? undefined, - }; -} - -export async function saveAIPreferences( - brandId: string, - config: AIAuthConfig -): Promise<{ success: boolean; error?: string }> { - try { - await pool.query( - `INSERT INTO brand_ai_settings - (brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - ON CONFLICT (brand_id) DO UPDATE - SET provider = EXCLUDED.provider, - api_key = EXCLUDED.api_key, - organization_id = EXCLUDED.organization_id, - base_url = EXCLUDED.base_url, - model = EXCLUDED.model, - max_tokens = EXCLUDED.max_tokens, - updated_at = NOW()`, - [ - brandId, - config.provider, - config.api_key || null, - config.organization_id || null, - config.base_url || null, - config.model || "gpt-4o-mini", - config.max_tokens || 4000, - ], - ); - return { success: true }; - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : "Failed to save AI preferences", - }; - } -} - -export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> { - if (!config.api_key?.trim()) { - return { ok: false, message: "API key is required" }; - } - - try { - const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1"; - const url = `${baseUrl.replace(/\/$/, "")}/models`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${config.api_key}`, - }, - }); - - if (response.ok) { - return { ok: true, message: "Connection successful! Your API key is valid." }; - } else if (response.status === 401) { - return { ok: false, message: "Invalid API key. Please check and try again." }; - } else { - return { ok: false, message: `Error: ${response.status} ${response.statusText}` }; - } - } catch { - return { ok: false, message: "Could not connect. Check your network and API key." }; - } -} \ No newline at end of file diff --git a/src/actions/storefront.ts b/src/actions/storefront.ts index 67cbd31..f940725 100644 --- a/src/actions/storefront.ts +++ b/src/actions/storefront.ts @@ -98,12 +98,17 @@ export async function getStorefrontStops(brandId: string): Promise { const { rows } = await pool.query( - `SELECT id, brand_id, name, description, price::float8 AS price, + `SELECT id, brand_id, name, description, + price::float8 / 100 AS price, type, image_url, is_taxable, pickup_type, active FROM products WHERE brand_id = $1 @@ -137,16 +142,16 @@ export async function getStorefrontData(slug: string): Promise { export type StorefrontWholesaleSettings = { invoice_business_name: string | null; - invoice_business_address: string | null; - invoice_business_phone: string | null; - invoice_business_email: string | null; - invoice_business_website: string | null; }; /** - * Public wholesale-settings record for a brand — just the public-facing - * invoice/business info used by the storefront contact page. Returns - * `null` if the brand or its wholesale settings row doesn't exist. + * Public wholesale-settings record for a brand — just the invoice + * business name used by the storefront contact page. Returns `null` + * if the brand or its wholesale settings row doesn't exist. + * + * NOTE: only `invoice_business_name` is exposed; other invoice-contact + * fields don't exist on `wholesale_settings` yet and shouldn't be + * surfaced here. If you need them, add a migration first. */ export async function getStorefrontWholesaleSettings( slug: string, @@ -154,8 +159,7 @@ export async function getStorefrontWholesaleSettings( const brand = await getStorefrontBrandBySlug(slug); if (!brand) return null; const { rows } = await pool.query( - `SELECT invoice_business_name, invoice_business_address, - invoice_business_phone, invoice_business_email, invoice_business_website + `SELECT invoice_business_name FROM wholesale_settings WHERE brand_id = $1 LIMIT 1`, @@ -206,14 +210,17 @@ export async function getStorefrontBrandName( } /** - * Look up a single public stop by its URL slug, plus the brand's - * active products. Used by `/[brand]/stops/[slug]` pages. + * Look up a single public stop by its id, plus the brand's active + * products. Used by `/[brand]/stops/[id]` pages. + * + * NOTE: The URL parameter is named `[id]` (not `[slug]`) because the + * `stops` table has no slug column. URLs use the stop's UUID. * * Returns `null` if the stop is not public, not active, or its date has * already passed. Returns the empty product array if the brand has no * active products at the moment. */ -export async function getStorefrontStopBySlug(slug: string): Promise<{ +export async function getStorefrontStopById(id: string): Promise<{ stop: StorefrontStop; products: StorefrontProduct[]; } | null> { @@ -222,11 +229,11 @@ export async function getStorefrontStopBySlug(slug: string): Promise<{ date::text AS date, time, cutoff_date::text AS cutoff_date, status, notes, is_public FROM stops - WHERE slug = $1 + WHERE id = $1 AND is_public = true AND status = 'active' LIMIT 1`, - [slug], + [id], ); const stop = stopRows[0]; if (!stop) return null; diff --git a/src/app/indian-river-direct/contact/ContactClientPage.tsx b/src/app/indian-river-direct/contact/ContactClientPage.tsx index 493057c..8af3533 100644 --- a/src/app/indian-river-direct/contact/ContactClientPage.tsx +++ b/src/app/indian-river-direct/contact/ContactClientPage.tsx @@ -10,10 +10,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront"; type BrandSettings = { invoice_business_name: string | null; - invoice_business_address: string | null; - invoice_business_phone: string | null; - invoice_business_email: string | null; - invoice_business_website: string | null; }; type FormState = { @@ -90,7 +86,7 @@ export default function IndianRiverContactPage() { {[ { label: "Address", - value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida", + value: "Indian River Region, Florida", icon: , iconPath: }, @@ -102,7 +98,7 @@ export default function IndianRiverContactPage() { }, { label: "Email", - value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com", + value: "Info@indianriverdirect.com", icon: , iconPath: null }, diff --git a/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx b/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx index 20ca0a6..2d0ceec 100644 --- a/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx +++ b/src/app/indian-river-direct/stops/IndianRiverStopsList.tsx @@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr {upcomingStops.map((stop) => (
diff --git a/src/app/indian-river-direct/stops/[slug]/error.tsx b/src/app/indian-river-direct/stops/[id]/error.tsx similarity index 100% rename from src/app/indian-river-direct/stops/[slug]/error.tsx rename to src/app/indian-river-direct/stops/[id]/error.tsx diff --git a/src/app/indian-river-direct/stops/[slug]/loading.tsx b/src/app/indian-river-direct/stops/[id]/loading.tsx similarity index 100% rename from src/app/indian-river-direct/stops/[slug]/loading.tsx rename to src/app/indian-river-direct/stops/[id]/loading.tsx diff --git a/src/app/indian-river-direct/stops/[slug]/page.tsx b/src/app/indian-river-direct/stops/[id]/page.tsx similarity index 98% rename from src/app/indian-river-direct/stops/[slug]/page.tsx rename to src/app/indian-river-direct/stops/[id]/page.tsx index 5468918..6ba0294 100644 --- a/src/app/indian-river-direct/stops/[slug]/page.tsx +++ b/src/app/indian-river-direct/stops/[id]/page.tsx @@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { getStorefrontStopBySlug } from "@/actions/storefront"; +import { getStorefrontStopById } from "@/actions/storefront"; import { formatDate } from "@/lib/format-date"; type Product = { @@ -53,7 +53,7 @@ export default function StopPage() { useEffect(() => { async function load() { - const result = await getStorefrontStopBySlug(slug); + const result = await getStorefrontStopById(slug); if (!result) return; setStop(result.stop as unknown as Stop); diff --git a/src/app/tuxedo/contact/ContactClientPage.tsx b/src/app/tuxedo/contact/ContactClientPage.tsx index 041ef3f..7a089b6 100644 --- a/src/app/tuxedo/contact/ContactClientPage.tsx +++ b/src/app/tuxedo/contact/ContactClientPage.tsx @@ -9,10 +9,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront"; type BrandSettings = { invoice_business_name: string | null; - invoice_business_address: string | null; - invoice_business_phone: string | null; - invoice_business_email: string | null; - invoice_business_website: string | null; }; type FormState = { @@ -72,7 +68,7 @@ export default function TuxedoContactPage() {

Farm Address

- {brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"} + 59751 David Road, Olathe, CO 81425

@@ -96,8 +92,8 @@ export default function TuxedoContactPage() {

Email

- - {brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"} + + orders@tuxedocorn.com diff --git a/src/app/tuxedo/stops/TuxedoStopsList.tsx b/src/app/tuxedo/stops/TuxedoStopsList.tsx index c8f75c4..bdf04cf 100644 --- a/src/app/tuxedo/stops/TuxedoStopsList.tsx +++ b/src/app/tuxedo/stops/TuxedoStopsList.tsx @@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props) {upcomingStops.map((stop) => (
diff --git a/src/app/tuxedo/stops/[slug]/error.tsx b/src/app/tuxedo/stops/[id]/error.tsx similarity index 100% rename from src/app/tuxedo/stops/[slug]/error.tsx rename to src/app/tuxedo/stops/[id]/error.tsx diff --git a/src/app/tuxedo/stops/[slug]/loading.tsx b/src/app/tuxedo/stops/[id]/loading.tsx similarity index 100% rename from src/app/tuxedo/stops/[slug]/loading.tsx rename to src/app/tuxedo/stops/[id]/loading.tsx diff --git a/src/app/tuxedo/stops/[slug]/page.tsx b/src/app/tuxedo/stops/[id]/page.tsx similarity index 98% rename from src/app/tuxedo/stops/[slug]/page.tsx rename to src/app/tuxedo/stops/[id]/page.tsx index 0c331bf..9b3a9c1 100644 --- a/src/app/tuxedo/stops/[slug]/page.tsx +++ b/src/app/tuxedo/stops/[id]/page.tsx @@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect"; import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import LayoutContainer from "@/components/layout/LayoutContainer"; -import { getStorefrontStopBySlug } from "@/actions/storefront"; +import { getStorefrontStopById } from "@/actions/storefront"; import { formatDate } from "@/lib/format-date"; type Product = { @@ -52,7 +52,7 @@ export default function StopPage() { useEffect(() => { async function load() { - const result = await getStorefrontStopBySlug(slug); + const result = await getStorefrontStopById(slug); if (!result) return; setStop(result.stop as unknown as Stop); diff --git a/src/components/storefront/StopCard.tsx b/src/components/storefront/StopCard.tsx index 72fd7df..51f590a 100644 --- a/src/components/storefront/StopCard.tsx +++ b/src/components/storefront/StopCard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { formatDate } from "@/lib/format-date"; type StopCardProps = { - slug: string; + id: string; city: string; state: string; date: string; @@ -13,7 +13,7 @@ type StopCardProps = { }; export default function StopCard({ - slug, city, state, date, time, location, + id, city, state, date, time, location, brandSlug = "tuxedo", brandAccent = "green", }: StopCardProps) { const ctaBg = @@ -25,7 +25,7 @@ export default function StopCard({ return (
- + {/* Location */}
@@ -56,7 +56,7 @@ export default function StopCard({ {/* CTA */}
Shop This Stop