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
* 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(
brandId: string,
): Promise<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
FROM products
WHERE brand_id = $1
@@ -137,16 +142,16 @@ export async function getStorefrontData(slug: string): Promise<StorefrontData> {
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<StorefrontWholesaleSettings>(
`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;