fix(storefront): align store actions with actual DB schema

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'.
This commit is contained in:
Nora
2026-06-25 17:18:53 -06:00
parent 2daa8fd4b6
commit 2cf811a66b
13 changed files with 37 additions and 146 deletions
-108
View File
@@ -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<AIRow>(
`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." };
}
}
+22 -15
View File
@@ -98,12 +98,17 @@ export async function getStorefrontStops(brandId: string): Promise<StorefrontSto
/** /**
* Active products visible on the storefront. Same shape as the admin * Active products visible on the storefront. Same shape as the admin
* product list but without the brand-internal columns (cost, supplier, …). * product list but without the brand-internal columns (cost, supplier, …).
*
* Note: products.price is stored in cents (NUMERIC). We return it as a
* float in **dollars** so callers can format directly as `$${price}`
* without doing the cents-to-dollars math.
*/ */
export async function getStorefrontProducts( export async function getStorefrontProducts(
brandId: string, brandId: string,
): Promise<StorefrontProduct[]> { ): Promise<StorefrontProduct[]> {
const { rows } = await pool.query<StorefrontProduct>( const { rows } = await pool.query<StorefrontProduct>(
`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 type, image_url, is_taxable, pickup_type, active
FROM products FROM products
WHERE brand_id = $1 WHERE brand_id = $1
@@ -137,16 +142,16 @@ export async function getStorefrontData(slug: string): Promise<StorefrontData> {
export type StorefrontWholesaleSettings = { export type StorefrontWholesaleSettings = {
invoice_business_name: string | null; 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 * Public wholesale-settings record for a brand — just the invoice
* invoice/business info used by the storefront contact page. Returns * business name used by the storefront contact page. Returns `null`
* `null` if the brand or its wholesale settings row doesn't exist. * 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( export async function getStorefrontWholesaleSettings(
slug: string, slug: string,
@@ -154,8 +159,7 @@ export async function getStorefrontWholesaleSettings(
const brand = await getStorefrontBrandBySlug(slug); const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null; if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>( const { rows } = await pool.query<StorefrontWholesaleSettings>(
`SELECT invoice_business_name, invoice_business_address, `SELECT invoice_business_name
invoice_business_phone, invoice_business_email, invoice_business_website
FROM wholesale_settings FROM wholesale_settings
WHERE brand_id = $1 WHERE brand_id = $1
LIMIT 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 * Look up a single public stop by its id, plus the brand's active
* active products. Used by `/[brand]/stops/[slug]` pages. * 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 * 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 * already passed. Returns the empty product array if the brand has no
* active products at the moment. * active products at the moment.
*/ */
export async function getStorefrontStopBySlug(slug: string): Promise<{ export async function getStorefrontStopById(id: string): Promise<{
stop: StorefrontStop; stop: StorefrontStop;
products: StorefrontProduct[]; products: StorefrontProduct[];
} | null> { } | null> {
@@ -222,11 +229,11 @@ export async function getStorefrontStopBySlug(slug: string): Promise<{
date::text AS date, time, cutoff_date::text AS cutoff_date, date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public status, notes, is_public
FROM stops FROM stops
WHERE slug = $1 WHERE id = $1
AND is_public = true AND is_public = true
AND status = 'active' AND status = 'active'
LIMIT 1`, LIMIT 1`,
[slug], [id],
); );
const stop = stopRows[0]; const stop = stopRows[0];
if (!stop) return null; if (!stop) return null;
@@ -10,10 +10,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; 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 = { type FormState = {
@@ -90,7 +86,7 @@ export default function IndianRiverContactPage() {
{[ {[
{ {
label: "Address", label: "Address",
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida", value: "Indian River Region, Florida",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />, icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" /> iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
}, },
@@ -102,7 +98,7 @@ export default function IndianRiverContactPage() {
}, },
{ {
label: "Email", label: "Email",
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com", value: "Info@indianriverdirect.com",
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />, icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
iconPath: null iconPath: null
}, },
@@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr
{upcomingStops.map((stop) => ( {upcomingStops.map((stop) => (
<Link <Link
key={stop.id} key={stop.id}
href={`/${brandSlug}/stops/${stop.slug}`} href={`/${brandSlug}/stops/${stop.id}`}
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200" className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
> >
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopBySlug } from "@/actions/storefront"; import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -53,7 +53,7 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const result = await getStorefrontStopBySlug(slug); const result = await getStorefrontStopById(slug);
if (!result) return; if (!result) return;
setStop(result.stop as unknown as Stop); setStop(result.stop as unknown as Stop);
+3 -7
View File
@@ -9,10 +9,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; 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 = { type FormState = {
@@ -72,7 +68,7 @@ export default function TuxedoContactPage() {
</div> </div>
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3> <h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
<p className="text-sm text-stone-500 leading-relaxed"> <p className="text-sm text-stone-500 leading-relaxed">
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"} 59751 David Road, Olathe, CO 81425
</p> </p>
</div> </div>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"> <div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
@@ -96,8 +92,8 @@ export default function TuxedoContactPage() {
</svg> </svg>
</div> </div>
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3> <h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors"> <a href="mailto:orders@tuxedocorn.com" className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"} orders@tuxedocorn.com
</a> </a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
{upcomingStops.map((stop) => ( {upcomingStops.map((stop) => (
<Link <Link
key={stop.id} key={stop.id}
href={`/${brandSlug}/stops/${stop.slug}`} href={`/${brandSlug}/stops/${stop.id}`}
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200" className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
> >
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
@@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopBySlug } from "@/actions/storefront"; import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -52,7 +52,7 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const result = await getStorefrontStopBySlug(slug); const result = await getStorefrontStopById(slug);
if (!result) return; if (!result) return;
setStop(result.stop as unknown as Stop); setStop(result.stop as unknown as Stop);
+4 -4
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type StopCardProps = { type StopCardProps = {
slug: string; id: string;
city: string; city: string;
state: string; state: string;
date: string; date: string;
@@ -13,7 +13,7 @@ type StopCardProps = {
}; };
export default function StopCard({ export default function StopCard({
slug, city, state, date, time, location, id, city, state, date, time, location,
brandSlug = "tuxedo", brandAccent = "green", brandSlug = "tuxedo", brandAccent = "green",
}: StopCardProps) { }: StopCardProps) {
const ctaBg = const ctaBg =
@@ -25,7 +25,7 @@ export default function StopCard({
return ( return (
<div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300"> <div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300">
<Link href={`/${brandSlug}/stops/${slug}`} className="block p-7"> <Link href={`/${brandSlug}/stops/${id}`} className="block p-7">
{/* Location */} {/* Location */}
<div className="flex items-start gap-4 mb-5"> <div className="flex items-start gap-4 mb-5">
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}> <div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}>
@@ -56,7 +56,7 @@ export default function StopCard({
{/* CTA */} {/* CTA */}
<div className="px-7 pb-7 pt-1"> <div className="px-7 pb-7 pt-1">
<Link <Link
href={`/${brandSlug}/stops/${slug}`} href={`/${brandSlug}/stops/${id}`}
className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`} className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`}
> >
Shop This Stop Shop This Stop