refactor(storefront): remove supabase shim and restore customer storefronts
The legacy src/lib/supabase.ts shim returned { data: null } for every
query, so 15+ pages were silently rendering empty state — including the
two customer-facing storefronts (tuxedo, indian-river-direct) and
several v1 admin pages. Replace every caller with canonical access:
- New src/actions/storefront.ts server-action module: brand lookup,
public stops, active products, stop-by-slug, wholesale settings,
portal config. Uses the shared pg Pool and SECURITY DEFINER RPCs.
- src/actions/brand-settings.ts: getBrandSettingsPublic inlined the
brands + brand_settings LEFT JOIN wholesale_settings query (the
RPC it called did not exist; try/catch was masking the failure).
- src/actions/ai/preferences.ts: switched get/saveAIPreferences to
pool.query.
- src/actions/square-sync-ui.ts: new getSquareQueueCount action for
SquareSyncWidget (replaces shim count).
- src/app/api/{tuxedo,indian-river-direct}/schedule-pdf/route.ts:
use pool.query.
- next.config.ts: redirects /admin/{products/:id,reports,taxes,
settings/{shipping,integrations,billing}} → their v2 / settings
equivalents, then deleted those pages.
- Deleted src/lib/supabase.ts (no remaining imports).
Tests: 174/175 (unchanged from baseline; pre-existing getAdminUser
mock issue is tracked in Step 5).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type AIAuthConfig = {
|
||||
provider: string;
|
||||
@@ -11,6 +11,14 @@ export type AIAuthConfig = {
|
||||
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;
|
||||
@@ -18,35 +26,58 @@ export async function getAIPreferences(brandId: string): Promise<{
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
} | null> {
|
||||
const { data, error } = await supabase
|
||||
.from("brand_ai_settings")
|
||||
.select("api_key, organization_id, base_url, model, max_tokens")
|
||||
.eq("brand_id", brandId)
|
||||
.single();
|
||||
|
||||
if (error || !data) return null;
|
||||
return data;
|
||||
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 }> {
|
||||
const result = await supabase
|
||||
.from("brand_ai_settings")
|
||||
.upsert({
|
||||
brand_id: brandId,
|
||||
provider: config.provider,
|
||||
api_key: config.api_key || null,
|
||||
organization_id: config.organization_id || null,
|
||||
base_url: config.base_url || null,
|
||||
model: config.model || "gpt-4o-mini",
|
||||
max_tokens: config.max_tokens || 4000,
|
||||
updated_at: new Date().toISOString(),
|
||||
}) as { data: unknown; error: { message: string } | null };
|
||||
|
||||
if (result.error) return { success: false, error: result.error.message };
|
||||
return { success: true };
|
||||
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 }> {
|
||||
|
||||
@@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
||||
}
|
||||
}
|
||||
|
||||
// Public version for storefront pages — uses slug, no auth required
|
||||
// Public version for storefront pages — uses slug, no auth required.
|
||||
//
|
||||
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
|
||||
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
|
||||
// That RPC is referenced from this code path but is not defined in any
|
||||
// shipped migration, so the function-on-the-server call would throw
|
||||
// `function get_brand_settings_by_slug(unknown) does not exist` and the
|
||||
// storefront would silently fall back to defaults. The inlined query
|
||||
// has the same shape and never fails because of a missing RPC.
|
||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
|
||||
// crash the prerender — the page just falls back to its default brand
|
||||
// name and revalidates from a real request later.
|
||||
try {
|
||||
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||
"SELECT * FROM get_brand_settings_by_slug($1)",
|
||||
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
|
||||
FROM brands b
|
||||
JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
WHERE b.slug = $1
|
||||
LIMIT 1`,
|
||||
[brandSlug],
|
||||
);
|
||||
const data = rows[0];
|
||||
|
||||
@@ -76,4 +76,31 @@ export async function getSyncLog(brandId: string): Promise<{
|
||||
);
|
||||
|
||||
return { success: true, logs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending-item count for the dashboard widget badge. Used by
|
||||
* `SquareSyncWidget` so it can refresh without re-running the full
|
||||
* sync log query. Returns 0 when the caller isn't authorized — never
|
||||
* throws, so callers can render "0" while the auth check settles.
|
||||
*/
|
||||
export async function getSquareQueueCount(brandId: string): Promise<number> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return 0;
|
||||
try {
|
||||
assertBrandAccess(adminUser, brandId);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const { rows } = await pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM square_sync_queue
|
||||
WHERE brand_id = $1 AND status = 'pending'`,
|
||||
[brandId],
|
||||
);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,47 @@ export async function getStorefrontWholesaleSettings(
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export type WholesalePortalConfig = {
|
||||
wholesale_enabled: boolean | null;
|
||||
online_payment_enabled: boolean | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public wholesale portal flags for a brand. Used by the customer-facing
|
||||
* `/wholesale/portal` page to decide whether to allow sign-in and whether
|
||||
* to show the online-payment button. Doesn't require auth (the portal
|
||||
* already validated the session before this is called).
|
||||
*/
|
||||
export async function getWholesalePortalConfig(
|
||||
brandId: string,
|
||||
): Promise<WholesalePortalConfig | null> {
|
||||
const { rows } = await pool.query<WholesalePortalConfig>(
|
||||
`SELECT wholesale_enabled, online_payment_enabled
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = $1
|
||||
LIMIT 1`,
|
||||
[brandId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export type BrandName = { name: string };
|
||||
|
||||
/**
|
||||
* Fetch a brand's display name by id. Public — used by the wholesale
|
||||
* portal header to render the brand name without leaking any other
|
||||
* brand columns (logo, plan, stripe keys, etc.).
|
||||
*/
|
||||
export async function getStorefrontBrandName(
|
||||
brandId: string,
|
||||
): Promise<string | null> {
|
||||
const { rows } = await pool.query<BrandName>(
|
||||
`SELECT name FROM brands WHERE id = $1 LIMIT 1`,
|
||||
[brandId],
|
||||
);
|
||||
return rows[0]?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single public stop by its URL slug, plus the brand's
|
||||
* active products. Used by `/[brand]/stops/[slug]` pages.
|
||||
|
||||
Reference in New Issue
Block a user