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:
Nora
2026-06-25 17:12:28 -06:00
parent 9f3dc9b68e
commit 2daa8fd4b6
22 changed files with 241 additions and 1146 deletions
+55 -24
View File
@@ -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 }> {
+15 -5
View File
@@ -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];
+27
View File
@@ -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;
}
}
+41
View File
@@ -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.