migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)

This commit is contained in:
2026-06-07 03:25:22 +00:00
parent eb9621d238
commit 99a3d66636
26 changed files with 1216 additions and 1776 deletions
+68 -40
View File
@@ -36,12 +36,12 @@
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq, count } from "drizzle-orm";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus = export type BillingSubscriptionStatus =
| "active" | "active"
| "trialing" | "trialing"
@@ -97,48 +97,76 @@ export async function getBillingOverview(
if (!brandId) return { success: false, error: "brandId required" }; if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info) // 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch( // Replicate the RPC inline via raw SQL on tenants + plans.
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, const planRes = await pool.query<{
{ plan_tier: string;
method: "POST", plan_name: string | null;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, max_users: number;
body: JSON.stringify({ p_brand_id: brandId }), max_stops_monthly: number;
} max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
const planData = planRes.ok ? await planRes.json() : null; const planData = planRes.rows[0] ?? null;
// 2) Brand row: subscription state, name, stripe_customer_id // 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch( const brandRes = await pool.query<{
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`, name: string;
{ headers: svcHeaders(supabaseKey) } stripe_customer_id: string | null;
stripe_subscription_id: string | null;
stripe_subscription_status: string | null;
stripe_current_period_end: string | null;
}>(
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
FROM tenants WHERE id = $1`,
[brandId]
); );
const brandRows = brandRes.ok ? await brandRes.json() : []; const brand = brandRes.rows[0] ?? null;
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
// 3) Enabled add-ons (feature flags) // 3) Enabled add-ons (feature flags from brand_settings)
const addonsRes = await fetch( const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`, `SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {}; const flags = addonsRes.rows[0]?.feature_flags ?? {};
const enabledAddons: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags ?? {})) {
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
}
// 4) Active product count — same semantics as the dashboard // 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in // (active=true). Keeps the billing page in sync with /admin
// sync with /admin "Active Products" stat. // "Active Products" stat.
const productRes = await fetch( const productCountRows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`, db
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } } .select({ value: count() })
.from(products)
.where(
and(
eq(products.tenantId, brandId),
eq(products.active, true)
)
)
); );
const productCountHeader = productRes.headers.get("Content-Range"); const activeProductCount = Number(productCountRows[0]?.value ?? 0);
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
// 5) Admin context (so we know if the user can manage / is platform) // 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -146,7 +174,7 @@ export async function getBillingOverview(
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings; const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ────────────────────────────────────────────────── // ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey; const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
const limits = { const limits = {
max_users: planData?.max_users ?? 1, max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10, max_stops_monthly: planData?.max_stops_monthly ?? 10,
@@ -209,7 +237,7 @@ export async function getBillingOverview(
success: true, success: true,
data: { data: {
brandId, brandId,
brandName: brand?.name ?? planData?.brand_name ?? null, brandName: brand?.name ?? planData?.plan_name ?? null,
planTier, planTier,
planCycle, planCycle,
planMonthlyPrice, planMonthlyPrice,
@@ -220,7 +248,7 @@ export async function getBillingOverview(
currentPeriodEnd: brand?.stripe_current_period_end ?? null, currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits, limits,
usage, usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>, enabledAddons,
addons, addons,
displayedInvoiceAmount, displayedInvoiceAmount,
monthlyTotal, monthlyTotal,
+5 -8
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type LineItem = { type LineItem = {
id: string; id: string;
@@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession(
})); }));
// Get brand name for Stripe metadata // Get brand name for Stripe metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await brandRes.json() as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// use default // use default
} }
+6 -9
View File
@@ -1,6 +1,6 @@
"use server"; "use server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
/** /**
* Creates a Stripe PaymentIntent for the supplied cart so the browser * Creates a Stripe PaymentIntent for the supplied cart so the browser
@@ -63,17 +63,14 @@ export async function createRetailPaymentIntent(
} }
// Pull the brand name for Stripe receipts + metadata // Pull the brand name for Stripe receipts + metadata
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
let brandName = "Route Commerce"; let brandName = "Route Commerce";
if (supabaseUrl && supabaseKey && brandId) { if (brandId) {
try { try {
const brandRes = await fetch( const brandRes = await pool.query<{ name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, "SELECT name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = (await brandRes.json()) as Array<{ name: string }>; if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
if (brands?.[0]?.name) brandName = brands[0].name;
} catch { } catch {
// ignore — use default // ignore — use default
} }
+20 -29
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Price ID config ──────────────────────────────────────────────────────────── // ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables // Maps plan/addon keys to Stripe price IDs via environment variables
@@ -43,16 +43,12 @@ export async function createStripeCheckoutSession(
const priceId = getPriceId(priceKey); const priceId = getPriceId(priceKey);
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get brand's Stripe customer ID // Get brand's Stripe customer ID
const custRes = await fetch( const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`, "SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
{ headers: { ...svcHeaders(supabaseKey) } } [brandId]
); );
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>; const brand = custRes.rows[0];
const brand = brands[0];
if (!brand?.stripe_customer_id) { if (!brand?.stripe_customer_id) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
} }
@@ -123,20 +119,15 @@ export async function cancelAddonSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get active subscription for this brand // Get active subscription for this brand
const subRes = await fetch( const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`, "SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!subRes.ok) return { success: false, error: "Failed to get subscription" }; if (subRes.rows.length === 0) {
const subData = await subRes.json() as { stripe_subscription_id?: string }; return { success: false, error: "No active subscription found" };
}
const subData = subRes.rows[0];
if (!subData?.stripe_subscription_id) { if (!subData?.stripe_subscription_id) {
return { success: false, error: "No active subscription found" }; return { success: false, error: "No active subscription found" };
} }
@@ -162,14 +153,14 @@ export async function cancelAddonSubscription(
}); });
// Disable the feature flag locally // Disable the feature flag locally
await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, await pool.query(
{ "SELECT set_brand_feature($1, $2, $3)",
method: "POST", [brandId, addonKey, false]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }), } catch {
} // best-effort: webhook reconciliation will eventually fix the flag
); }
return { success: true }; return { success: true };
} }
+75 -84
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> { export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured" }; if (!stripeKey) return { success: false, error: "Stripe not configured" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get stripe_customer_id from tenants table
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
// Get stripe_customer_id from brands table [brandId]
const custRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
{ headers: svcHeaders(supabaseKey) }
); );
const custData = await custRes.json(); const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
if (!stripeCustomerId) { if (!stripeCustomerId) {
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." }; return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
@@ -49,20 +45,15 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
return { success: false, error: "Invalid plan tier" }; return { success: false, error: "Invalid plan tier" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
const res = await fetch( [brandId, planTier]
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, );
{ return { success: true };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to update plan tier" };
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }), }
}
);
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
return { success: true };
} }
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> { export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
const res = await fetch( [brandId, stripeCustomerId]
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, );
{ return { success: true };
method: "POST", } catch {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return { success: false, error: "Failed to update Stripe customer ID" };
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }), }
}
);
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
return { success: true };
} }
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Replicate get_brand_plan_info via a JOIN on tenants + plans
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query<{
plan_tier: string;
const res = await fetch( plan_name: string | null;
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, max_users: number;
{ max_stops_monthly: number;
method: "POST", max_products: number;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, usage: { users: number; stops_this_month: number; products: number } | null;
body: JSON.stringify({ p_brand_id: brandId }), }>(
} `SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
); );
if (!res.ok) return { success: false, error: "Failed to fetch plan info" }; const data = res.rows[0];
const data = await res.json(); if (!data) return { success: false, error: "Brand not found" };
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
return { success: true, data }; return { success: true, data };
} }
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> { export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
// get_brand_features returns JSONB — a single object, not an array // get_brand_features returns JSONB — a single object, not an array
if (!res.ok) return {}; const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
const data = await res.json(); "SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
if (typeof data !== "object" || data === null) return {}; [brandId]
return data as Record<string, boolean>; );
const flags = res.rows[0]?.feature_flags ?? {};
if (!flags || typeof flags !== "object") return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true || v === "true" || v === 1 || v === "1";
}
return out;
} }
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> { export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
const res = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, );
{ const data = res.rows;
method: "POST", if (!Array.isArray(data)) return [];
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, return data.slice(0, limit);
body: JSON.stringify({ p_brand_id: brandId }), } catch {
} return [];
); }
}
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
}
+60 -102
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models"; import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
// ── Types ──────────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────────
@@ -33,33 +33,31 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ───────────────────────────────────────────────────── // ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> { export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<{
provider: string | null;
const response = await fetch( api_key: string | null;
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`, org_id: string | null;
{ model: string | null;
method: "POST", custom_endpoint: string | null;
headers: { }>(
...svcHeaders(supabaseKey!), "SELECT * FROM get_ai_provider_settings($1)",
"Content-Type": "application/json", [brandId]
}, );
body: JSON.stringify({ p_brand_id: brandId }), const data = res.rows[0];
if (!data) {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
} }
); return {
provider: (data.provider as AIProvider) ?? "openai",
if (!response.ok) { apiKey: data.api_key ?? "",
orgId: data.org_id ?? undefined,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint ?? undefined,
};
} catch {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" }; return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
} }
const data = await response.json();
return {
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.api_key ?? "",
orgId: data.org_id,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint,
};
} }
// ── Save AI provider settings ─────────────────────────────────────────────────── // ── Save AI provider settings ───────────────────────────────────────────────────
@@ -75,9 +73,6 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId); const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings }; const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const payload = { const payload = {
provider: merged.provider, provider: merged.provider,
api_key: merged.apiKey, api_key: merged.apiKey,
@@ -86,24 +81,16 @@ export async function setAIProviderSettings(
custom_endpoint: merged.customEndpoint ?? null, custom_endpoint: merged.customEndpoint ?? null,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`, await pool.query(
{ "SELECT set_ai_provider_settings($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(payload)]
headers: { );
...svcHeaders(supabaseKey!), return { success: true };
"Content-Type": "application/json", } catch (err) {
}, const msg = err instanceof Error ? err.message : "Failed to save";
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }), return { success: false, error: msg };
}
);
const data = await response.json();
if (!response.ok || !data) {
return { success: false, error: data?.message ?? "Failed to save" };
} }
return { success: true };
} }
// ── Dynamic AI client factory ──────────────────────────────────────────────────── // ── Dynamic AI client factory ────────────────────────────────────────────────────
@@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ───────────────────────────────────────────────────────── // ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> { export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`, );
{ return res.rows ?? [];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey!), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data ?? [];
} }
export async function upsertCustomIntegration( export async function upsertCustomIntegration(
@@ -234,25 +212,16 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; const res = await pool.query<CustomIntegration>(
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
const response = await fetch( [brandId, JSON.stringify(integration)]
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`, );
{ return { success: true, integrations: res.rows };
method: "POST", } catch (err) {
headers: { const msg = err instanceof Error ? err.message : "Failed to save";
...svcHeaders(supabaseKey!), return { success: false, error: msg };
"Content-Type": "application/json", }
},
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
}
);
const data = await response.json();
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
return { success: true, integrations: data };
} }
export async function deleteCustomIntegration( export async function deleteCustomIntegration(
@@ -263,25 +232,14 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; await pool.query(
"SELECT delete_custom_integration($1, $2)",
const response = await fetch( [brandId, integrationId]
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`, );
{ return { success: true };
method: "POST", } catch (err) {
headers: { const msg = err instanceof Error ? err.message : "Failed to delete";
...svcHeaders(supabaseKey!), return { success: false, error: msg };
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
}
);
if (!response.ok) {
const data = await response.json();
return { success: false, error: data?.message ?? "Failed to delete" };
} }
return { success: true };
} }
+50 -78
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -24,28 +24,25 @@ export type SaveCredentialsResult =
// ── Resend Credentials ───────────────────────────────────────────────────────── // ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> { export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
api_key: string | null;
const response = await fetch( from_email: string | null;
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`, from_name: string | null;
{ }>(
method: "POST", "SELECT * FROM get_resend_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }), );
} const data = res.rows[0];
); if (!data) return { api_key: null, from_email: null, from_name: null };
return {
if (!response.ok) { api_key: data.api_key ?? null,
from_email: data.from_email ?? null,
from_name: data.from_name ?? null,
};
} catch {
return { api_key: null, from_email: null, from_name: null }; return { api_key: null, from_email: null, from_name: null };
} }
const data = await response.json();
return {
api_key: data?.api_key ?? null,
from_email: data?.from_email ?? null,
from_name: data?.from_name ?? null,
};
} }
export async function saveResendCredentials( export async function saveResendCredentials(
@@ -66,9 +63,6 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getResendCredentials(brandId); const current = await getResendCredentials(brandId);
const merged = { const merged = {
@@ -77,50 +71,39 @@ export async function saveResendCredentials(
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name, from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`, await pool.query(
{ "SELECT set_resend_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ return { success: true };
p_brand_id: brandId, } catch {
p_credentials: merged,
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to save Resend credentials" }; return { success: false, error: "Failed to save Resend credentials" };
} }
return { success: true };
} }
// ── Twilio Credentials ───────────────────────────────────────────────────────── // ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> { export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const res = await pool.query<{
account_sid: string | null;
const response = await fetch( auth_token: string | null;
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`, phone_number: string | null;
{ }>(
method: "POST", "SELECT * FROM get_twilio_credentials($1)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [brandId]
body: JSON.stringify({ p_brand_id: brandId }), );
} const data = res.rows[0];
); if (!data) return { account_sid: null, auth_token: null, phone_number: null };
return {
if (!response.ok) { account_sid: data.account_sid ?? null,
auth_token: data.auth_token ?? null,
phone_number: data.phone_number ?? null,
};
} catch {
return { account_sid: null, auth_token: null, phone_number: null }; return { account_sid: null, auth_token: null, phone_number: null };
} }
const data = await response.json();
return {
account_sid: data?.account_sid ?? null,
auth_token: data?.auth_token ?? null,
phone_number: data?.phone_number ?? null,
};
} }
export async function saveTwilioCredentials( export async function saveTwilioCredentials(
@@ -141,9 +124,6 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge // Get current credentials to merge
const current = await getTwilioCredentials(brandId); const current = await getTwilioCredentials(brandId);
const merged = { const merged = {
@@ -152,23 +132,15 @@ export async function saveTwilioCredentials(
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number, phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
}; };
const response = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`, await pool.query(
{ "SELECT set_twilio_credentials($1, $2::jsonb)",
method: "POST", [brandId, JSON.stringify(merged)]
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, );
body: JSON.stringify({ return { success: true };
p_brand_id: brandId, } catch {
p_credentials: merged,
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to save Twilio credentials" }; return { success: false, error: "Failed to save Twilio credentials" };
} }
return { success: true };
} }
// ── Test Connection Functions ───────────────────────────────────────────────── // ── Test Connection Functions ─────────────────────────────────────────────────
@@ -229,4 +201,4 @@ export async function testTwilioConnection(
} catch (err) { } catch (err) {
return { ok: false, message: "Network error - please check your connection" }; return { ok: false, message: "Network error - please check your connection" };
} }
} }
+10 -14
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -110,30 +112,26 @@ export async function syncInventoryToSquare(
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Build product name → quantity map // Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
try { try {
// Fetch product details from RC // Fetch product details from RC
const productIds = items.map((i) => i.productId); const productIds = items.map((i) => i.productId);
const productsRes = await fetch( const rows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`, db
{ .select({ id: products.id, name: products.name })
headers: { ...svcHeaders(supabaseKey) }, .from(products)
} .where(inArray(products.id, productIds))
); );
if (!productsRes.ok) { if (rows.length === 0 && productIds.length > 0) {
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] }; return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
} }
const products: Array<{ id: string; name: string }> = await productsRes.json();
// Find Square catalog items matching product names and reduce quantity // Find Square catalog items matching product names and reduce quantity
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = []; const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
for (const product of products) { for (const product of rows) {
const qty = itemQtyMap.get(product.id) ?? 0; const qty = itemQtyMap.get(product.id) ?? 0;
if (qty <= 0) continue; if (qty <= 0) continue;
@@ -177,8 +175,6 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
const errors: string[] = []; const errors: string[] = [];
const synced = 0; const synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
// Fetch Square inventory counts for the location // Fetch Square inventory counts for the location
+39 -29
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
function getSquareBaseUrl(accessToken: string) { function getSquareBaseUrl(accessToken: string) {
return process.env.SQUARE_ENVIRONMENT === "production" return process.env.SQUARE_ENVIRONMENT === "production"
@@ -85,8 +85,6 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Determine sync start time — last sync or 30 days ago // Determine sync start time — last sync or 30 days ago
const since = settings.square_last_sync_at const since = settings.square_last_sync_at
@@ -131,36 +129,48 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
// Use idempotency key to avoid duplicates // Use idempotency key to avoid duplicates
const idempotencyKey = `square_${payment.id}`; const idempotencyKey = `square_${payment.id}`;
const createRes = await fetch( // Call SECURITY DEFINER RPC create_order_with_items
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`, let createOk = false;
{ let errText = "";
method: "POST", try {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, const rpcRes = await pool.query(
body: JSON.stringify({ `SELECT * FROM create_order_with_items(
p_idempotency_key: idempotencyKey, $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10
p_customer_name: customerName, )`,
p_customer_email: customerEmail, [
p_customer_phone: "", idempotencyKey,
p_stop_id: null, // Square orders don't have RC stop_id customerName,
p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({ customerEmail,
id: null, // product lookup not available in this flow "",
quantity: li.quantity, null, // Square orders don't have RC stop_id
fulfillment: "shipping", JSON.stringify(
})), lineItems.map((li: { name: string; quantity: number; price: number }) => ({
p_subtotal: total, id: null, // product lookup not available in this flow
p_payment_processor: "square", quantity: li.quantity,
p_payment_status: "paid", fulfillment: "shipping",
p_payment_transaction_id: payment.id, }))
}), ),
total,
"square",
"paid",
payment.id,
]
);
createOk = rpcRes.rows.length > 0;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
// 409 / unique violation = already exists (idempotent)
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
createOk = true;
} else {
errText = msg.slice(0, 100);
} }
); }
if (createRes.ok || createRes.status === 409) { if (createOk) {
// 409 = already exists (idempotent)
synced++; synced++;
} else { } else {
const errText = await createRes.text(); errors.push(`Payment ${payment.id}: ${errText}`);
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
} }
} catch (err) { } catch (err) {
errors.push(`Payment ${payment.id}: ${String(err)}`); errors.push(`Payment ${payment.id}: ${String(err)}`);
+21 -40
View File
@@ -3,7 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square"; import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SquareCatalogItem = { export type SquareCatalogItem = {
id: string; id: string;
@@ -135,25 +135,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
let synced = 0; let synced = 0;
try { try {
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query) // Fetch wholesale products via SECURITY DEFINER RPC
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const rpcRes = await pool.query<{
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const rpcRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!rpcRes.ok) {
const errText = await rpcRes.text();
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
}
const products: Array<{
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
@@ -163,7 +146,12 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
hp_sku: string | null; hp_sku: string | null;
hp_item_id: string | null; hp_item_id: string | null;
default_pickup_location: string | null; default_pickup_location: string | null;
}> = await rpcRes.json(); }>(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
const products = rpcRes.rows;
// Filter to available products only // Filter to available products only
const availableProducts = products.filter((p) => p.availability === "available"); const availableProducts = products.filter((p) => p.availability === "available");
@@ -247,8 +235,6 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const errors: string[] = []; const errors: string[] = [];
let synced = 0; let synced = 0;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
let cursor: string | undefined; let cursor: string | undefined;
do { do {
@@ -263,15 +249,13 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0; const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
const imageUrl = obj.item.image_url ?? null; const imageUrl = obj.item.image_url ?? null;
// Sync to RC via bulk_upsert_products RPC // Sync to RC via SECURITY DEFINER RPC bulk_upsert_products
const upsertRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, await pool.query(
{ "SELECT bulk_upsert_products($1, $2::jsonb)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, brandId,
body: JSON.stringify({ JSON.stringify([
p_brand_id: brandId,
p_products: [
{ {
name: item.name, name: item.name,
description: item.description ?? "", description: item.description ?? "",
@@ -280,15 +264,12 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
active: true, active: true,
image_url: imageUrl, image_url: imageUrl,
}, },
], ]),
}), ]
} );
);
if (upsertRes.ok) {
synced++; synced++;
} else { } catch (e: unknown) {
const errText = await upsertRes.text(); const errText = e instanceof Error ? e.message : String(e);
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`); errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
} }
} }
+4 -14
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type SyncLogEntry = { export type SyncLogEntry = {
id: string; id: string;
@@ -70,20 +70,10 @@ export async function getSyncLog(brandId: string): Promise<{
return { success: false, logs: [] }; return { success: false, logs: [] };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const { rows: logs } = await pool.query<SyncLogEntry>(
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; "SELECT * FROM square_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT 10",
[brandId]
const response = await fetch(
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
{
headers: svcHeaders(supabaseKey),
}
); );
if (!response.ok) {
return { success: false, logs: [] };
}
const logs: SyncLogEntry[] = await response.json();
return { success: true, logs }; return { success: true, logs };
} }
+23 -53
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import Stripe from "stripe"; import Stripe from "stripe";
/** /**
@@ -19,25 +19,13 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" }; if (!adminUser) return { is_connected: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Get brand's payment settings via SECURITY DEFINER RPC
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; const { rows } = await pool.query<{ stripe_user_id: string | null }>(
"SELECT * FROM get_brand_payment_settings($1)",
// Get brand's payment settings [brandId]
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!res.ok) { const stripeUserId = rows[0]?.stripe_user_id ?? null;
return { is_connected: false, error: "Failed to fetch payment settings" };
}
const data = await res.json();
const stripeUserId = data?.stripe_user_id;
if (!stripeUserId) { if (!stripeUserId) {
return { is_connected: false }; return { is_connected: false };
@@ -183,28 +171,17 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean; success: boolean;
error?: string; error?: string;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
// Save to payment_settings via RPC "SELECT set_stripe_connect_account($1, $2)",
const res = await fetch( [brandId, accountId]
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, const error = e instanceof Error ? e.message : String(e);
body: JSON.stringify({
p_brand_id: brandId,
p_stripe_user_id: accountId,
}),
}
);
if (!res.ok) {
const error = await res.text();
return { success: false, error: `Failed to save: ${error}` }; return { success: false, error: `Failed to save: ${error}` };
} }
return { success: true };
} }
/** /**
@@ -220,23 +197,16 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" }; return { success: false, error: "Not authorized" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; // SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
const res = await fetch( "SELECT disconnect_stripe_connect($1)",
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`, [brandId]
{ );
method: "POST", return { success: true };
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, } catch {
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { success: false, error: "Failed to disconnect Stripe account" }; return { success: false, error: "Failed to disconnect Stripe account" };
} }
return { success: true };
} }
/** /**
+12 -16
View File
@@ -2,7 +2,7 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export type WholesaleLoginResult = export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string } | { success: true; token: string; userId: string; customerId: string }
@@ -54,21 +54,17 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
return { success: false, error: "No session returned from auth" }; return { success: false, error: "No session returned from auth" };
} }
// Find the wholesale customer record for this user // Find the wholesale customer record for this user (SECURITY DEFINER RPC).
const customerRes = await fetch( // The result is intentionally not consumed here — the portal page resolves
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, // the actual customer on load using the cookie's access_token.
{ try {
method: "POST", await pool.query(
headers: { "SELECT * FROM get_wholesale_customer_by_user($1, $2)",
...svcHeaders(supabaseAnonKey), ["00000000-0000-0000-0000-000000000000", data.user.id]
"Content-Type": "application/json", );
}, } catch {
body: JSON.stringify({ // Customer may not be linked yet; portal will resolve.
p_brand_id: "placeholder", // will use any-brand lookup below }
p_user_id: data.user.id,
}),
}
);
// If no brand_id known, try all brands — just use first active one found // If no brand_id known, try all brands — just use first active one found
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer // For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
+177 -246
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope"; import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function registerWholesaleCustomer(params: { export async function registerWholesaleCustomer(params: {
brandId: string; brandId: string;
@@ -11,43 +11,33 @@ export async function registerWholesaleCustomer(params: {
email: string; email: string;
phone?: string; phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> { }): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{
success: boolean;
const response = await fetch( error?: string;
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`, requires_approval?: boolean;
{ }>(
method: "POST", `SELECT * FROM register_wholesale_customer($1, $2, $3, $4, $5)`,
headers: { [
...svcHeaders(supabaseKey), params.brandId,
"Content-Type": "application/json", params.companyName,
}, params.contactName ?? null,
body: JSON.stringify({ params.email,
p_brand_id: params.brandId, params.phone ?? null,
p_company_name: params.companyName, ]
p_contact_name: params.contactName ?? null, );
p_email: params.email, const result = Array.isArray(rows) ? rows[0] : rows;
p_phone: params.phone ?? null, if (!result?.success) {
}), return { success: false, error: result?.error ?? "Registration failed." };
} }
); return {
success: true,
if (!response.ok) { requiresApproval: result.requires_approval ?? false,
const err = await response.json(); };
// Supabase error format: { "message": "...", "code": "...", ... } } catch (e: unknown) {
// Our RPC error format: { "success": false, "error": "..." } const err = e instanceof Error ? e.message : String(e);
return { success: false, error: err.message ?? err.error ?? "Registration failed." }; return { success: false, error: err || "Registration failed." };
} }
const data = await response.json();
// Normalize: RPC may return an array (single row) or a plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Registration failed." };
}
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
} }
export async function getPendingWholesaleRegistrations(brandId: string) { export async function getPendingWholesaleRegistrations(brandId: string) {
@@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return []; if (!adminUser) return [];
if (!adminUser.can_manage_orders) return []; if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_pending_wholesale_registrations($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`, );
{ return rows;
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function approveWholesaleRegistration( export async function approveWholesaleRegistration(
@@ -88,33 +70,19 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" }; return { success: false, error: "Not authorized to operate on this brand" };
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM approve_wholesale_registration($1, $2, $3)",
const response = await fetch( [registrationId, brandId, action]
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`, );
{ const result = Array.isArray(rows) ? rows[0] : rows;
method: "POST", if (!result?.success) {
headers: { return { success: false, error: result?.error ?? "Failed to process registration." };
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_registration_id: registrationId,
p_brand_id: brandId,
p_action: action,
}),
} }
); return { success: true };
} catch (e: unknown) {
if (!response.ok) return { success: false, error: "Failed to process registration." }; return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Failed to process registration." };
} }
return { success: true };
} }
export async function getWholesaleCustomerByUser( export async function getWholesaleCustomerByUser(
@@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
const response = await fetch( [brandId, userId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, );
{ return (rows[0] as {
method: "POST", id: string;
headers: { user_id: string;
...svcHeaders(supabaseKey), company_name: string;
"Content-Type": "application/json", contact_name: string;
}, email: string;
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }), phone: string;
} account_status: string;
); role: string;
brand_id: string;
if (!response.ok) return null; } | undefined) ?? null;
const data = await response.json(); } catch {
return data ?? null; return null;
}
} }
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode) // Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
@@ -165,40 +134,41 @@ export async function getWholesaleCustomer(
role: string; role: string;
brand_id: string; brand_id: string;
} | null> { } | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
const response = await fetch( FROM wholesale_customers
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`, WHERE id = $1
{ LIMIT 1`,
headers: svcHeaders(supabaseKey), [customerId]
} );
); return (rows[0] as {
id: string;
if (!response.ok) return null; user_id: string;
const data = await response.json(); company_name: string;
return data?.[0] ?? null; contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
} }
export async function getWholesaleProducts(brandId: string) { export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too // Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
const response = await fetch( [brandId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`, );
{ return rows;
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function submitWholesaleOrder(params: { export async function submitWholesaleOrder(params: {
@@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number; orderTotal?: number;
idempotent?: boolean; idempotent?: boolean;
}> { }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency // Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID(); const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: {
unit_price: i.unit_price, unit_price: i.unit_price,
})); }));
const response = await fetch( let result: {
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`, success?: boolean;
{ error?: string;
method: "POST", order_id?: string;
headers: { invoice_number?: string | null;
...svcHeaders(supabaseKey), status?: string;
"Content-Type": "application/json", deposit_required?: number;
}, credit_limit?: number;
body: JSON.stringify({ outstanding_balance?: number;
p_brand_id: params.brandId, order_total?: number;
p_customer_id: params.customerId, idempotent?: boolean;
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null, } | null = null;
p_items: itemsJson,
p_notes: params.notes ?? null,
p_checkout_session_id: checkoutSessionId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to create order." }; try {
const data = await response.json(); const { rows } = await pool.query(
// Normalize array response (RETURNING single row) to plain object `SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`,
const result = Array.isArray(data) ? data[0] : data; [
params.brandId,
params.customerId,
params.anticipatedPickupDate ?? null,
JSON.stringify(itemsJson),
params.notes ?? null,
checkoutSessionId,
]
);
result = Array.isArray(rows) ? rows[0] : rows;
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to create order." };
}
if (!result.success) { if (!result?.success) {
return { return {
success: false, success: false,
error: result.error ?? "Failed to create order.", error: result?.error ?? "Failed to create order.",
creditLimit: result.credit_limit, creditLimit: result?.credit_limit,
outstandingBalance: result.outstanding_balance, outstandingBalance: result?.outstanding_balance,
orderTotal: result.order_total, orderTotal: result?.order_total,
}; };
} }
// Fire webhook — fire-and-forget, don't block the response // Fire webhook — fire-and-forget, don't block the response
enqueueWholesaleWebhookForOrderCreated( enqueueWholesaleWebhookForOrderCreated(
result.order_id, result.order_id!,
params.brandId, params.brandId,
params.customerId, params.customerId,
result.invoice_number, result.invoice_number ?? null,
Number(result.deposit_required) || 0 Number(result.deposit_required) || 0
).catch(() => {}); ).catch(() => {});
return { return {
success: true, success: true,
orderId: result.order_id, orderId: result.order_id,
invoiceNumber: result.invoice_number, invoiceNumber: result.invoice_number ?? undefined,
status: result.status, status: result.status,
depositRequired: result.deposit_required, depositRequired: result.deposit_required,
idempotent: result.idempotent ?? false, idempotent: result.idempotent ?? false,
@@ -287,18 +260,15 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) { export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try { try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; await pool.query(
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; "SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { [
method: "POST", "order_created",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, orderId,
body: JSON.stringify({ brandId,
p_event_type: "order_created", JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }),
p_order_id: orderId, ]
p_brand_id: brandId, );
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
}),
});
} catch (_) {} } catch (_) {}
} }
@@ -344,23 +314,15 @@ export type WholesalePricingOverride = {
}; };
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> { export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
const response = await fetch( [customerId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`, );
{ return rows as WholesalePricingOverride[];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
} }
export async function upsertWholesaleCustomerPricing(params: { export async function upsertWholesaleCustomerPricing(params: {
@@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string; productId: string;
customUnitPrice: number; customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
const response = await fetch( [params.customerId, params.productId, params.customUnitPrice]
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" };
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
p_custom_unit_price: params.customUnitPrice,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
return { success: true };
} }
export async function deleteWholesaleCustomerPricing(params: { export async function deleteWholesaleCustomerPricing(params: {
customerId: string; customerId: string;
productId: string; productId: string;
}): Promise<{ success: boolean; error?: string }> { }): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
const response = await fetch( [params.customerId, params.productId]
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`, );
{ return { success: true };
method: "POST", } catch (e: unknown) {
headers: { return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" };
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
return { success: true };
} }
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> { export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; try {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
const response = await fetch( [customerId]
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`, );
{ return rows as WholesaleCustomerOrder[];
method: "POST", } catch {
headers: { return [];
...svcHeaders(supabaseKey), }
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
} }
+335 -520
View File
File diff suppressed because it is too large Load Diff
+15 -23
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -97,30 +97,22 @@ export async function GET(request: Request) {
); );
} }
// Store token + location_id in payment_settings via upsert // Store token + location_id in payment_settings via SECURITY DEFINER RPC
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
const upsertResponse = await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
{ [
method: "POST", brandId,
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, "square",
body: JSON.stringify({ null, // stripe_publishable_key
p_brand_id: brandId, null, // stripe_secret_key
p_provider: "square", null, // stripe_user_id
p_square_access_token: accessToken, accessToken,
p_square_location_id: locationId, locationId,
}), null, // square_sync_enabled (not changed here)
} null, // square_inventory_mode (not changed here)
]
); );
if (!upsertResponse.ok) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_failed", request.url)
);
}
} catch (err) { } catch (err) {
return NextResponse.redirect( return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_error", request.url) new URL("/admin/settings/payments?error=square_token_save_error", request.url)
+15 -12
View File
@@ -3,7 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products"; import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products";
import { syncOrdersFromSquare } from "@/actions/square-orders"; import { syncOrdersFromSquare } from "@/actions/square-orders";
import { getPaymentSettings } from "@/actions/payments"; import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
@@ -58,19 +58,22 @@ export async function POST(request: Request) {
} }
// Update square_last_sync_at and square_last_sync_error // Update square_last_sync_at and square_last_sync_error
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const lastError = syncErrors.length > 0 ? syncErrors.slice(0, 5).join("; ") : null; const lastError = syncErrors.length > 0 ? syncErrors.slice(0, 5).join("; ") : null;
await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, { await pool.query(
method: "POST", "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [
body: JSON.stringify({ brandId,
p_brand_id: brandId, null, // provider (not changed)
p_square_sync_enabled: true, null, // stripe_publishable_key
p_square_inventory_mode: settings?.square_inventory_mode ?? "none", null, // stripe_secret_key
}), null, // stripe_user_id
}); null, // square_access_token
null, // square_location_id
true, // square_sync_enabled
settings?.square_inventory_mode ?? "none",
]
);
return NextResponse.json({ return NextResponse.json({
success: syncErrors.length === 0, success: syncErrors.length === 0,
+17 -37
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { syncProductsToSquare } from "@/actions/square-products"; import { syncProductsToSquare } from "@/actions/square-products";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,24 +18,19 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "brand_id required" }, { status: 400 }); return NextResponse.json({ error: "brand_id required" }, { status: 400 });
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; // Claim a pending sync entry from the queue via SECURITY DEFINER RPC
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; let entries: Array<{ id: string; brand_id: string }> = [];
try {
const claimRes = await fetch( const { rows } = await pool.query<{ id: string; brand_id: string }>(
`${supabaseUrl}/rest/v1/rpc/claim_square_sync_queue`, "SELECT * FROM claim_square_sync_queue($1)",
{ [brandId]
method: "POST", );
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" }, entries = rows;
body: JSON.stringify({ p_brand_id: brandId }), } catch (e: unknown) {
} const errText = e instanceof Error ? e.message : String(e);
);
if (!claimRes.ok) {
const errText = await claimRes.text();
return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 }); return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 });
} }
const entries = await claimRes.json();
if (!entries || entries.length === 0 || entries[0] === null) { if (!entries || entries.length === 0 || entries[0] === null) {
return NextResponse.json({ processed: 0, message: "No pending entries" }); return NextResponse.json({ processed: 0, message: "No pending entries" });
} }
@@ -46,29 +41,14 @@ export async function GET(request: Request) {
const newStatus = result.success ? "done" : "failed"; const newStatus = result.success ? "done" : "failed";
const lastError = result.errors.length > 0 ? result.errors[0] : null; const lastError = result.errors.length > 0 ? result.errors[0] : null;
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`, "SELECT update_square_sync_timestamp($1, $2)",
{ [entry.brand_id, lastError]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: entry.brand_id,
p_error: lastError,
}),
}
); );
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${entry.id}`, "UPDATE square_sync_queue SET status = $1, processed_at = $2, last_error = $3 WHERE id = $4",
{ [newStatus, new Date().toISOString(), lastError, entry.id]
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
status: newStatus,
processed_at: new Date().toISOString(),
last_error: lastError,
}),
}
); );
return NextResponse.json({ return NextResponse.json({
@@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
type InvoiceSettings = {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
};
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@@ -11,78 +34,40 @@ export async function GET(
const { orderId } = await params; const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token"); const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Token-based customer download ──────────────────────────────────────────── // ── Token-based customer download ────────────────────────────────────────────
if (token) { if (token) {
// Look up order by ID + token. Falls back to a direct select so the // Look up order by ID + token. The invoice_token is checked server-side.
// invoice_token is checked server-side (not exposed to the client). const tokenRes = await pool.query<{ brand_id: string }>(
const orderRes = await fetch( "SELECT brand_id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`, [orderId, token]
{
headers: svcHeaders(supabaseKey),
}
); );
if (!orderRes.ok) { if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
// Proceed to generate PDF — order token is verified // Proceed to generate PDF — order token is verified
const brandId = orders[0].brand_id; const brandId = tokenRes.rows[0].brand_id;
// Fetch full order data for PDF // Fetch full order data for PDF
const fullRes = await fetch( const fullRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!fullRes.ok) { const allOrders = fullRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
const allOrders = await fullRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const order = allOrders.find(o => o.id === orderId); const order = allOrders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
// Fetch brand-specific settings for invoice header // Fetch brand-specific settings for invoice header
const settingsRes = await fetch( const settingsRes = await pool.query<InvoiceSettings>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, "SELECT * FROM get_wholesale_settings($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), { return new NextResponse(Buffer.from(pdfBytes), {
@@ -103,50 +88,23 @@ export async function GET(
} }
// Fetch the order directly by ID with brand scoping // Fetch the order directly by ID with brand scoping
const orderRes = await fetch( const orderRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [adminUser.brand_id ?? null]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? undefined }),
}
); );
if (!orderRes.ok) { const orders = orderRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
const orders = await orderRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const order = orders.find(o => o.id === orderId); const order = orders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Order not found", { status: 404 }); return new NextResponse("Order not found", { status: 404 });
} }
const settingsRes = await fetch( const settingsRes = await pool.query<InvoiceSettings>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, "SELECT * FROM get_wholesale_settings($1)",
{ [order.brand_id]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
@@ -158,27 +116,7 @@ export async function GET(
}); });
} }
async function buildInvoicePdf( async function buildInvoicePdf(order: OrderRow, settings: InvoiceSettings) {
order: {
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
},
settings: {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}
) {
const pdfDoc = await PDFDocument.create(); const pdfDoc = await PDFDocument.create();
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
@@ -1,6 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@@ -9,24 +25,17 @@ export async function GET(
const { orderId } = await params; const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token"); const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!process.env.DATABASE_URL) {
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
return new NextResponse("Server misconfiguration", { status: 500 }); return new NextResponse("Server misconfiguration", { status: 500 });
} }
// ── Token-gated download (customer portal) ────────────────────────────────── // ── Token-gated download (customer portal) ──────────────────────────────────
if (token) { if (token) {
const orderRes = await fetch( const tokenRes = await pool.query<{ id: string }>(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`, "SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
{ headers: svcHeaders(supabaseKey) } [orderId, token]
); );
if (!orderRes.ok || orderRes.status === 204) { if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
return new NextResponse("Not found", { status: 404 }); return new NextResponse("Not found", { status: 404 });
} }
} }
@@ -36,64 +45,38 @@ export async function GET(
let brandId = "00000000-0000-0000-0000-000000000000"; let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id // First try direct order lookup to get brand_id
const directRes = await fetch( const directRes = await pool.query<{ brand_id: string }>(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`, "SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1",
{ headers: svcHeaders(supabaseKey) } [orderId]
); );
if (directRes.ok) { if (directRes.rows.length > 0) {
const direct = await directRes.json(); brandId = directRes.rows[0].brand_id;
if (direct && direct.length > 0) {
brandId = direct[0].brand_id;
}
} }
const orderRes = await fetch( const orderRes = await pool.query<OrderRow>(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, "SELECT * FROM get_wholesale_orders($1)",
{ [brandId]
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!orderRes.ok) { const allOrders = orderRes.rows;
return new NextResponse("Failed to fetch order", { status: 500 });
}
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
const allOrders = await orderRes.json() as OrderRow[];
const order = allOrders.find(o => o.id === orderId); const order = allOrders.find(o => o.id === orderId);
if (!order) { if (!order) {
return new NextResponse("Order not found", { status: 404 }); return new NextResponse("Order not found", { status: 404 });
} }
// Fetch settings for brand header // Fetch settings for brand header
const settingsRes = await fetch( const settingsRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, invoice_business_name?: string;
{ invoice_business_address?: string;
method: "POST", invoice_business_phone?: string;
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, invoice_business_email?: string;
body: JSON.stringify({ p_brand_id: order.brand_id }), invoice_business_website?: string;
} }>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
); );
const settingsData = await settingsRes.json(); const settings = settingsRes.rows[0] ?? {};
const settings = settingsData ?? {};
const pdfBytes = await buildInvoicePdf(order, settings); const pdfBytes = await buildInvoicePdf(order, settings);
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// POST /api/wholesale/notifications/pickup-reminder // POST /api/wholesale/notifications/pickup-reminder
// Scans for fulfilled orders past their anticipated pickup date that haven't been // Scans for fulfilled orders past their anticipated pickup date that haven't been
@@ -9,24 +9,8 @@ import { svcHeaders } from "@/lib/svc-headers";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST() { export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Find orders: fulfilled status, past anticipated pickup date, not yet picked up // Find orders: fulfilled status, past anticipated pickup date, not yet picked up
const ordersRes = await fetch( const ordersRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_wholesale_overdue_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({}),
}
);
if (!ordersRes.ok) {
return NextResponse.json({ error: "Failed to fetch overdue orders" }, { status: 500 });
}
const overdueOrders = await ordersRes.json() as Array<{
id: string; id: string;
brand_id: string; brand_id: string;
customer_id: string; customer_id: string;
@@ -37,12 +21,16 @@ export async function POST() {
notification_email: string | null; notification_email: string | null;
from_email: string | null; from_email: string | null;
invoice_business_email: string | null; invoice_business_email: string | null;
}>; }>(
"SELECT * FROM get_wholesale_overdue_orders()"
);
if (overdueOrders.length === 0) { if (ordersRes.rows.length === 0) {
return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 }); return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 });
} }
const overdueOrders = ordersRes.rows;
let enqueued = 0; let enqueued = 0;
let skipped = 0; let skipped = 0;
@@ -55,33 +43,28 @@ export async function POST() {
const adminEmail = const adminEmail =
order.notification_email ?? order.from_email ?? order.invoice_business_email; order.notification_email ?? order.from_email ?? order.invoice_business_email;
const enqueueRes = await fetch( try {
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, await pool.query(
{ "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
method: "POST", [
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, order.brand_id,
body: JSON.stringify({ order.customer_id,
p_brand_id: order.brand_id, order.id,
p_customer_id: order.customer_id, "unclaimed_pickup",
p_order_id: order.id, order.customer_email,
p_type: "unclaimed_pickup", adminEmail,
p_email_to: order.customer_email, `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
p_email_cc: adminEmail, `
p_subject: `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
p_body_html: `
<h2>Order Overdue for Pickup</h2> <h2>Order Overdue for Pickup</h2>
<p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p> <p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p>
${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""} ${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""}
<p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p> <p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p>
`, `,
p_body_text: `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`, `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
}), ]
} );
);
if (enqueueRes.ok) {
enqueued++; enqueued++;
} else { } catch {
skipped++; skipped++;
} }
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale"; import { markWholesaleNotificationSent } from "@/actions/wholesale";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
import type { NotificationRecipient } from "@/actions/wholesale"; import type { NotificationRecipient } from "@/actions/wholesale";
// POST /api/wholesale/notifications/send // POST /api/wholesale/notifications/send
@@ -18,23 +18,7 @@ export async function POST() {
); );
} }
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const pendingRes = await pool.query<{
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const pendingRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: null, p_limit: 20 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending notifications" }, { status: 500 });
}
const notifications = await pendingRes.json() as Array<{
id: string; id: string;
type: string; type: string;
email_to: string; email_to: string;
@@ -46,12 +30,17 @@ export async function POST() {
order_id: string | null; order_id: string | null;
customer_id: string; customer_id: string;
invoice_business_email: string | null; invoice_business_email: string | null;
}>; }>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[null, 20]
);
if (notifications.length === 0) { if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 }); return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 });
} }
const notifications = pendingRes.rows;
// Prefetch settings for each unique brand so we can resolve notification_recipients // Prefetch settings for each unique brand so we can resolve notification_recipients
const brandIds = [...new Set(notifications.map(n => n.brand_id))]; const brandIds = [...new Set(notifications.map(n => n.brand_id))];
const brandSettingsMap: Record<string, { const brandSettingsMap: Record<string, {
@@ -62,13 +51,17 @@ export async function POST() {
}> = {}; }> = {};
await Promise.all(brandIds.map(async (bid) => { await Promise.all(brandIds.map(async (bid) => {
const r = await fetch(`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, { const r = await pool.query<{
method: "POST", notification_recipients: NotificationRecipient[];
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, notification_email: string | null;
body: JSON.stringify({ p_brand_id: bid }), from_email: string | null;
}); invoice_business_email: string | null;
if (r.ok) { }>(
const data = await r.json(); "SELECT * FROM get_wholesale_settings($1)",
[bid]
);
if (r.rows.length > 0) {
const data = r.rows[0];
brandSettingsMap[bid] = { brandSettingsMap[bid] = {
notification_recipients: data?.notification_recipients ?? [], notification_recipients: data?.notification_recipients ?? [],
notification_email: data?.notification_email ?? null, notification_email: data?.notification_email ?? null,
@@ -128,21 +121,20 @@ export async function POST() {
const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text); const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text);
// Log a separate notification entry for audit trail // Log a separate notification entry for audit trail
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, { await pool.query(
method: "POST", "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, [
body: JSON.stringify({ n.brand_id,
p_brand_id: n.brand_id, n.customer_id,
p_customer_id: n.customer_id, n.order_id ?? null,
p_order_id: n.order_id ?? null, n.type,
p_type: n.type, recipient.email,
p_email_to: recipient.email, null,
p_email_cc: null, n.subject,
p_subject: n.subject, n.body_html,
p_body_html: n.body_html, n.body_text,
p_body_text: n.body_text, ]
}), );
});
if (ok) sent++; else failed++; if (ok) sent++; else failed++;
} }
@@ -179,8 +171,12 @@ async function sendOneEmail(
} }
async function triggerPickupReminder() { async function triggerPickupReminder() {
// Use the same Next.js server — the route is at /api/wholesale/notifications/pickup-reminder
// The pickup-reminder route is also exposed as a cron in vercel.json.
try { try {
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, { const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.VERCEL_URL;
if (!baseUrl) return;
await fetch(`${baseUrl}/api/wholesale/notifications/pickup-reminder`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
@@ -1,36 +1,17 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import crypto from "crypto"; import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
// POST /api/wholesale/webhooks/dispatch // POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs. // Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events. // Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
export async function POST() { export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; if (!process.env.DATABASE_URL) {
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (!supabaseUrl || !serviceRoleKey) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
} }
// Fetch pending webhooks // Fetch pending webhooks
const pendingRes = await fetch( const pendingRes = await pool.query<{
`${supabaseUrl}/rest/v1/rpc/get_pending_webhooks`,
{
method: "POST",
headers: {
...svcHeaders(serviceRoleKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_limit: 10 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending webhooks" }, { status: 500 });
}
const pending = await pendingRes.json() as Array<{
id: string; id: string;
brand_id: string; brand_id: string;
event_type: string; event_type: string;
@@ -39,12 +20,16 @@ export async function POST() {
attempts: number; attempts: number;
url: string; url: string;
secret: string; secret: string;
}>; }>(
"SELECT * FROM get_pending_webhooks($1)",
[10]
);
if (!pending || pending.length === 0) { if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 }); return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 });
} }
const pending = pendingRes.rows;
let dispatched = 0; let dispatched = 0;
for (const webhook of pending) { for (const webhook of pending) {
@@ -71,40 +56,40 @@ export async function POST() {
const responseText = await res.text().catch(() => ""); const responseText = await res.text().catch(() => "");
if (res.ok) { if (res.ok) {
await markSent(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); await markSent(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
dispatched++; dispatched++;
} else { } else {
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); await markFailed(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Network error"; const msg = err instanceof Error ? err.message : "Network error";
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, msg); await markFailed(webhook.id, msg);
} }
} }
return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched }); return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched });
} }
async function markSent(logId: string, key: string, url: string, response: string) { async function markSent(logId: string, response: string) {
await fetch( try {
`${url}/rest/v1/rpc/mark_webhook_sent`, await pool.query(
{ "SELECT mark_webhook_sent($1, $2)",
method: "POST", [logId, response]
headers: { ...svcHeaders(key), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_log_id: logId, p_response: response }), } catch {
} // best-effort
); }
} }
async function markFailed(logId: string, key: string, url: string, response: string) { async function markFailed(logId: string, response: string) {
await fetch( try {
`${url}/rest/v1/rpc/mark_webhook_failed`, await pool.query(
{ "SELECT mark_webhook_failed($1, $2)",
method: "POST", [logId, response]
headers: { ...svcHeaders(key), "Content-Type": "application/json" }, );
body: JSON.stringify({ p_log_id: logId, p_response: response }), } catch {
} // best-effort
); }
} }
export async function GET() { export async function GET() {
+45 -51
View File
@@ -5,10 +5,7 @@ import "server-only";
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing"; import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
import { svcHeaders } from "@/lib/svc-headers"; import { pool } from "@/lib/db";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// ── Subscription status types ────────────────────────────────────────────────── // ── Subscription status types ──────────────────────────────────────────────────
@@ -29,22 +26,18 @@ export interface BrandSubscription {
plan_tier: PlanTierKey; plan_tier: PlanTierKey;
} }
// ── RPC call helper ─────────────────────────────────────────────────────────── // ── RPC call helper (raw SQL via pg pool) ─────────────────────────────────────
async function rpc<T = unknown>( async function rpc<T = Record<string, unknown>>(
fn: string, fn: string,
body: Record<string, unknown> params: ReadonlyArray<unknown>
): Promise<T> { ): Promise<T> {
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, { const placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
method: "POST", const { rows } = await pool.query<Record<string, unknown>>(
headers: { ...svcHeaders(SERVICE_KEY), "Content-Type": "application/json" }, `SELECT * FROM ${fn}(${placeholders})`,
body: JSON.stringify(body), params as unknown[]
}); );
if (!res.ok) { return rows as unknown as T;
const err = await res.text();
throw new Error(`RPC ${fn} failed: ${err}`);
}
return res.json() as Promise<T>;
} }
// ── Feature sync ───────────────────────────────────────────────────────────── // ── Feature sync ─────────────────────────────────────────────────────────────
@@ -75,7 +68,7 @@ export async function syncSubscriptionFeatures(
if (tierItem) { if (tierItem) {
const newTier = priceToTier[tierItem.priceId]; const newTier = priceToTier[tierItem.priceId];
if (newTier) { if (newTier) {
await rpc("update_brand_plan_tier", { p_brand_id: brandId, p_plan_tier: newTier }); await rpc("update_brand_plan_tier", [brandId, newTier]);
} }
} }
@@ -84,7 +77,7 @@ export async function syncSubscriptionFeatures(
if (!priceId) continue; if (!priceId) continue;
const item = subscriptionItems.find((i) => i.priceId === priceId); const item = subscriptionItems.find((i) => i.priceId === priceId);
const enabled = item?.enabled ?? false; const enabled = item?.enabled ?? false;
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: enabled }); await rpc("set_brand_feature", [brandId, addonKey, enabled]);
} }
} }
@@ -103,11 +96,11 @@ export async function createOrUpdateSubscription(
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any }); const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
// Get brand's Stripe customer // Get brand's Stripe customer
const brands = await rpc<Array<{ stripe_customer_id: string | null }>>( const brandRows = await rpc<Array<BrandSubscription>>(
"get_brand_subscription", "get_brand_subscription",
{ p_brand_id: brandId } [brandId]
); );
const brandData = brands[0] as unknown as BrandSubscription | undefined; const brandData = brandRows[0];
const customerId = brandData?.stripe_customer_id; const customerId = brandData?.stripe_customer_id;
if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first."); if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first.");
@@ -117,7 +110,7 @@ export async function createOrUpdateSubscription(
if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`); if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`);
// Check if brand already has an active subscription — update it instead of creating new // Check if brand already has an active subscription — update it instead of creating new
const existingSubId = (brandData as unknown as { stripe_subscription_id?: string })?.stripe_subscription_id; const existingSubId = brandData?.stripe_subscription_id;
let subscription; let subscription;
if (existingSubId) { if (existingSubId) {
@@ -155,12 +148,12 @@ export async function createOrUpdateSubscription(
// Save subscription ID to brand // Save subscription ID to brand
const subData = subscription as unknown as { id: string; status: string; current_period_end: number }; const subData = subscription as unknown as { id: string; status: string; current_period_end: number };
await rpc("set_brand_subscription", { await rpc("set_brand_subscription", [
p_brand_id: brandId, brandId,
p_subscription_id: subData.id, subData.id,
p_status: subData.status, subData.status,
p_current_period_end: new Date(subData.current_period_end * 1000).toISOString(), new Date(subData.current_period_end * 1000).toISOString(),
}); ]);
const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null; const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null;
const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null; const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null;
@@ -179,8 +172,8 @@ export async function cancelBrandSubscription(
const stripeKey = process.env.STRIPE_SECRET_KEY; const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured"); if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
const brandData = await rpc<BrandSubscription[]>("get_brand_subscription", { p_brand_id: brandId }); const brandRows = await rpc<BrandSubscription[]>("get_brand_subscription", [brandId]);
const brand = brandData[0] as unknown as BrandSubscription | undefined; const brand = brandRows[0];
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel"); if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
const Stripe = (await import("stripe")).default; const Stripe = (await import("stripe")).default;
@@ -200,21 +193,21 @@ export async function cancelBrandSubscription(
// Disable the feature flag // Disable the feature flag
const addonKey = getAddonKeyFromPriceKey(priceKey); const addonKey = getAddonKeyFromPriceKey(priceKey);
if (addonKey) { if (addonKey) {
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }); await rpc("set_brand_feature", [brandId, addonKey, false]);
} }
} }
} else { } else {
// Cancel entire subscription // Cancel entire subscription
await stripe.subscriptions.cancel(brand.stripe_subscription_id); await stripe.subscriptions.cancel(brand.stripe_subscription_id);
await rpc("set_brand_subscription", { await rpc("set_brand_subscription", [
p_brand_id: brandId, brandId,
p_subscription_id: "", "",
p_status: "canceled", "canceled",
p_current_period_end: null, null,
}); ]);
// Disable all add-on features // Disable all add-on features
for (const addonKey of Object.keys(ADDONS) as AddonKey[]) { for (const addonKey of Object.keys(ADDONS) as AddonKey[]) {
await rpc("set_brand_feature", { p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }); await rpc("set_brand_feature", [brandId, addonKey, false]);
} }
} }
} }
@@ -222,24 +215,24 @@ export async function cancelBrandSubscription(
// ── Past due notification ───────────────────────────────────────────────────── // ── Past due notification ─────────────────────────────────────────────────────
export async function sendPastDueNotification(brandId: string): Promise<void> { export async function sendPastDueNotification(brandId: string): Promise<void> {
const brandData = await rpc<Array<{ name: string }>>("get_brand_subscription", { p_brand_id: brandId }); const brandRows = await rpc<Array<{ name: string }>>("get_brand_subscription", [brandId]);
const brand = brandData[0] as unknown as { name?: string } | undefined; const brand = brandRows[0];
const brandName = brand?.name ?? "your brand"; const brandName = brand?.name ?? "your brand";
const adminEmail = await getAdminEmail(brandId); const adminEmail = await getAdminEmail(brandId);
await rpc("enqueue_notification", { await rpc("enqueue_notification", [
p_brand_id: brandId, brandId,
p_email_to: adminEmail ?? "team@cielohermosa.com", adminEmail ?? "team@cielohermosa.com",
p_subject: `Payment Failed — ${brandName}`, `Payment Failed — ${brandName}`,
p_body_html: ` `
<h2>Payment Failed</h2> <h2>Payment Failed</h2>
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p> <p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
<p>Please update your payment method within 7 days to avoid service interruption.</p> <p>Please update your payment method within 7 days to avoid service interruption.</p>
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p> <p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
`, `,
p_body_text: `Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`, `Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
}); ]);
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -267,9 +260,10 @@ async function getAdminEmail(brandId: string): Promise<string | null> {
try { try {
const data = await rpc<{ notification_email?: string; from_email?: string }>( const data = await rpc<{ notification_email?: string; from_email?: string }>(
"get_wholesale_settings", "get_wholesale_settings",
{ p_brand_id: brandId } [brandId]
); );
return data?.notification_email ?? data?.from_email ?? null; const settings = Array.isArray(data) ? data[0] : data;
return settings?.notification_email ?? settings?.from_email ?? null;
} catch { } catch {
return null; return null;
} }
@@ -296,4 +290,4 @@ export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "a
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] }; if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
return null; return null;
} }
+18 -18
View File
@@ -10,7 +10,9 @@
* 3. Use isFeatureEnabled(brandId, "key") to gate UI elements * 3. Use isFeatureEnabled(brandId, "key") to gate UI elements
*/ */
import { svcHeaders } from "@/lib/svc-headers"; import { withTenant } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
export type BrandFeatureKey = export type BrandFeatureKey =
| "harvest_reach" | "harvest_reach"
@@ -170,25 +172,23 @@ export async function isFeatureEnabled(
async function fetchBrandFeatures( async function fetchBrandFeatures(
brandId: string brandId: string
): Promise<Record<BrandFeatureKey, boolean> | null> { ): Promise<Record<BrandFeatureKey, boolean> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) return null;
try { try {
const res = await fetch( const rows = await withTenant(brandId, (db) =>
`${supabaseUrl}/rest/v1/rpc/get_brand_features`, db
{ .select({ featureFlags: brandSettings.featureFlags })
method: "POST", .from(brandSettings)
headers: { .where(eq(brandSettings.tenantId, brandId))
...svcHeaders(supabaseKey), .limit(1)
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
); );
if (!res.ok) return null; const flags = rows[0]?.featureFlags as Record<string, unknown> | null | undefined;
const data = await res.json(); if (!flags || typeof flags !== "object") return null;
return data ?? null; // Coerce to boolean (JSONB may store as boolean, string, or number)
const result = {} as Record<BrandFeatureKey, boolean>;
for (const key of Object.keys(ADDON_CATALOG) as BrandFeatureKey[]) {
const v = (flags as Record<string, unknown>)[key];
result[key] = v === true || v === "true" || v === 1 || v === "1";
}
return result;
} catch { } catch {
return null; return null;
} }
+19 -56
View File
@@ -2,6 +2,7 @@
// Supports plans, add-ons, upgrades, cancellations, and customer portal // Supports plans, add-ons, upgrades, cancellations, and customer portal
import Stripe from "stripe"; import Stripe from "stripe";
import { pool } from "@/lib/db";
// Lazy-initialize Stripe client to avoid build-time errors when env vars aren't set // Lazy-initialize Stripe client to avoid build-time errors when env vars aren't set
let _stripe: Stripe | null = null; let _stripe: Stripe | null = null;
@@ -565,27 +566,17 @@ async function updateBrandSubscription(
periodEnd: number | null periodEnd: number | null
) { ) {
if (!brandId) return; if (!brandId) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`, "SELECT set_brand_subscription($1, $2, $3, $4, $5)",
{ [
method: "POST", brandId,
headers: { subscriptionId,
apikey: serviceKey, status,
"Content-Type": "application/json", planTier ?? null,
}, periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
body: JSON.stringify({ ]
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_subscription_status: status,
p_plan_tier: planTier,
p_current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
}),
}
); );
} catch (error) { } catch (error) {
console.error("Failed to update brand subscription:", error); console.error("Failed to update brand subscription:", error);
@@ -594,25 +585,11 @@ async function updateBrandSubscription(
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) { async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return; if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, "SELECT set_brand_feature($1, $2, $3)",
{ [brandId, addonKey, true]
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: true,
}),
}
); );
} catch (error) { } catch (error) {
console.error("Failed to enable addon feature:", error); console.error("Failed to enable addon feature:", error);
@@ -621,25 +598,11 @@ async function enableAddonFeature(brandId: string | undefined, addonKey: string
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) { async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return; if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try { try {
await fetch( await pool.query(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`, "SELECT set_brand_feature($1, $2, $3)",
{ [brandId, addonKey, false]
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: false,
}),
}
); );
} catch (error) { } catch (error) {
console.error("Failed to disable addon feature:", error); console.error("Failed to disable addon feature:", error);