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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user