diff --git a/src/actions/billing/billing-overview.ts b/src/actions/billing/billing-overview.ts index 9e12ff7..86fbe3d 100644 --- a/src/actions/billing/billing-overview.ts +++ b/src/actions/billing/billing-overview.ts @@ -36,12 +36,12 @@ */ 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"; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - export type BillingSubscriptionStatus = | "active" | "trialing" @@ -97,48 +97,76 @@ export async function getBillingOverview( if (!brandId) return { success: false, error: "brandId required" }; // 1) Plan info (plan_tier + limits + usage via get_brand_plan_info) - const planRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + // Replicate the RPC inline via raw SQL on tenants + plans. + const planRes = await pool.query<{ + plan_tier: string; + plan_name: string | null; + max_users: number; + 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 - const brandRes = await fetch( - `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`, - { headers: svcHeaders(supabaseKey) } + const brandRes = await pool.query<{ + name: string; + 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 = Array.isArray(brandRows) ? brandRows[0] : null; + const brand = brandRes.rows[0] ?? null; - // 3) Enabled add-ons (feature flags) - const addonsRes = 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 }), - } + // 3) Enabled add-ons (feature flags from brand_settings) + const addonsRes = await pool.query<{ feature_flags: Record | null }>( + `SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`, + [brandId] ); - const enabledAddons = addonsRes.ok ? await addonsRes.json() : {}; + const flags = addonsRes.rows[0]?.feature_flags ?? {}; + const enabledAddons: Record = {}; + 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 - // (active=true AND deleted_at IS NULL). Keeps the billing page in - // sync with /admin "Active Products" stat. - const productRes = await fetch( - `${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`, - { headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } } + // (active=true). Keeps the billing page in sync with /admin + // "Active Products" stat. + const productCountRows = await withTenant(brandId, (db) => + db + .select({ value: count() }) + .from(products) + .where( + and( + eq(products.tenantId, brandId), + eq(products.active, true) + ) + ) ); - const productCountHeader = productRes.headers.get("Content-Range"); - let activeProductCount = 0; - if (productCountHeader && productCountHeader.includes("/")) { - const total = productCountHeader.split("/").pop(); - activeProductCount = parseInt(total ?? "0", 10) || 0; - } + const activeProductCount = Number(productCountRows[0]?.value ?? 0); // 5) Admin context (so we know if the user can manage / is platform) const adminUser = await getAdminUser(); @@ -146,7 +174,7 @@ export async function getBillingOverview( const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings; // ── Normalize plan data ────────────────────────────────────────────────── - const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey; + const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter"); const limits = { max_users: planData?.max_users ?? 1, max_stops_monthly: planData?.max_stops_monthly ?? 10, @@ -209,7 +237,7 @@ export async function getBillingOverview( success: true, data: { brandId, - brandName: brand?.name ?? planData?.brand_name ?? null, + brandName: brand?.name ?? planData?.plan_name ?? null, planTier, planCycle, planMonthlyPrice, @@ -220,7 +248,7 @@ export async function getBillingOverview( currentPeriodEnd: brand?.stripe_current_period_end ?? null, limits, usage, - enabledAddons: (enabledAddons ?? {}) as Record, + enabledAddons, addons, displayedInvoiceAmount, monthlyTotal, diff --git a/src/actions/billing/retail-checkout.ts b/src/actions/billing/retail-checkout.ts index 5708122..5d8c6aa 100644 --- a/src/actions/billing/retail-checkout.ts +++ b/src/actions/billing/retail-checkout.ts @@ -1,6 +1,6 @@ "use server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; type LineItem = { id: string; @@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession( })); // 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"; try { - const brandRes = await fetch( - `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, - { headers: { ...svcHeaders(supabaseKey) } } + const brandRes = await pool.query<{ name: string }>( + "SELECT name FROM tenants WHERE id = $1 LIMIT 1", + [brandId] ); - const brands = await brandRes.json() as Array<{ name: string }>; - if (brands?.[0]?.name) brandName = brands[0].name; + if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name; } catch { // use default } diff --git a/src/actions/billing/retail-payment-intent.ts b/src/actions/billing/retail-payment-intent.ts index 90511b9..6ac0b81 100644 --- a/src/actions/billing/retail-payment-intent.ts +++ b/src/actions/billing/retail-payment-intent.ts @@ -1,6 +1,6 @@ "use server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; /** * 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 - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; let brandName = "Route Commerce"; - if (supabaseUrl && supabaseKey && brandId) { + if (brandId) { try { - const brandRes = await fetch( - `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`, - { headers: { ...svcHeaders(supabaseKey) } } + const brandRes = await pool.query<{ name: string }>( + "SELECT name FROM tenants WHERE id = $1 LIMIT 1", + [brandId] ); - const brands = (await brandRes.json()) as Array<{ name: string }>; - if (brands?.[0]?.name) brandName = brands[0].name; + if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name; } catch { // ignore — use default } diff --git a/src/actions/billing/stripe-checkout.ts b/src/actions/billing/stripe-checkout.ts index 2a6b6f9..2070761 100644 --- a/src/actions/billing/stripe-checkout.ts +++ b/src/actions/billing/stripe-checkout.ts @@ -1,7 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; // ── Price ID config ──────────────────────────────────────────────────────────── // Maps plan/addon keys to Stripe price IDs via environment variables @@ -43,16 +43,12 @@ export async function createStripeCheckoutSession( const priceId = getPriceId(priceKey); 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 - const custRes = await fetch( - `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`, - { headers: { ...svcHeaders(supabaseKey) } } + const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>( + "SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1", + [brandId] ); - const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>; - const brand = brands[0]; + const brand = custRes.rows[0]; if (!brand?.stripe_customer_id) { 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; 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 - const subRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_brand_subscription`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + const subRes = await pool.query<{ stripe_subscription_id: string | null }>( + "SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1", + [brandId] ); - if (!subRes.ok) return { success: false, error: "Failed to get subscription" }; - const subData = await subRes.json() as { stripe_subscription_id?: string }; + if (subRes.rows.length === 0) { + return { success: false, error: "No active subscription found" }; + } + const subData = subRes.rows[0]; if (!subData?.stripe_subscription_id) { return { success: false, error: "No active subscription found" }; } @@ -162,14 +153,14 @@ export async function cancelAddonSubscription( }); // Disable the feature flag locally - await fetch( - `${supabaseUrl}/rest/v1/rpc/set_brand_feature`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }), - } - ); + try { + await pool.query( + "SELECT set_brand_feature($1, $2, $3)", + [brandId, addonKey, false] + ); + } catch { + // best-effort: webhook reconciliation will eventually fix the flag + } return { success: true }; } \ No newline at end of file diff --git a/src/actions/billing/stripe-portal.ts b/src/actions/billing/stripe-portal.ts index 7277ff0..b77676b 100644 --- a/src/actions/billing/stripe-portal.ts +++ b/src/actions/billing/stripe-portal.ts @@ -1,7 +1,7 @@ "use server"; 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 }> { const adminUser = await getAdminUser(); @@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ const stripeKey = process.env.STRIPE_SECRET_KEY; 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 stripe_customer_id from brands table - const custRes = await fetch( - `${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`, - { headers: svcHeaders(supabaseKey) } + // Get stripe_customer_id from tenants table + const custRes = await pool.query<{ stripe_customer_id: string | null }>( + "SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1", + [brandId] ); - const custData = await custRes.json(); - const stripeCustomerId = custData?.[0]?.stripe_customer_id; + const stripeCustomerId = custRes.rows[0]?.stripe_customer_id; if (!stripeCustomerId) { 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" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - 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 }; + try { + await pool.query( + "SELECT update_brand_plan_tier($1, $2)", + [brandId, planTier] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to update plan tier" }; + } } 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" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - 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 }; + try { + await pool.query( + "SELECT update_brand_stripe_customer_id($1, $2)", + [brandId, stripeCustomerId] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to update Stripe customer ID" }; + } } export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { - 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_plan_info`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + // Replicate get_brand_plan_info via a JOIN on tenants + plans + const res = await pool.query<{ + plan_tier: string; + plan_name: string | null; + max_users: number; + 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] ); - if (!res.ok) return { success: false, error: "Failed to fetch plan info" }; - const data = await res.json(); - if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" }; + const data = res.rows[0]; + if (!data) return { success: false, error: "Brand not found" }; return { success: true, data }; } export async function getEnabledAddons(brandId: string): Promise> { - 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 - if (!res.ok) return {}; - const data = await res.json(); - if (typeof data !== "object" || data === null) return {}; - return data as Record; + const res = await pool.query<{ feature_flags: Record | null }>( + "SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1", + [brandId] + ); + const flags = res.rows[0]?.feature_flags ?? {}; + if (!flags || typeof flags !== "object") return {}; + const out: Record = {}; + 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 { - 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_wholesale_orders`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!res.ok) return []; - const data = await res.json(); - if (!Array.isArray(data)) return []; - return data.slice(0, limit); -} \ No newline at end of file + try { + const res = await pool.query( + "SELECT * FROM get_wholesale_orders($1)", + [brandId] + ); + const data = res.rows; + if (!Array.isArray(data)) return []; + return data.slice(0, limit); + } catch { + return []; + } +} diff --git a/src/actions/integrations/ai-providers.ts b/src/actions/integrations/ai-providers.ts index 4e7247b..c635ae8 100644 --- a/src/actions/integrations/ai-providers.ts +++ b/src/actions/integrations/ai-providers.ts @@ -1,7 +1,7 @@ "use server"; 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"; // ── Types ──────────────────────────────────────────────────────────────────────── @@ -33,33 +33,31 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1"; // ── Get AI provider settings ───────────────────────────────────────────────────── export async function getAIProviderSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey!), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId }), + try { + const res = await pool.query<{ + provider: string | null; + api_key: string | null; + org_id: string | null; + model: string | null; + custom_endpoint: string | null; + }>( + "SELECT * FROM get_ai_provider_settings($1)", + [brandId] + ); + const data = res.rows[0]; + if (!data) { + return { provider: "openai", apiKey: "", model: "gpt-4o-mini" }; } - ); - - if (!response.ok) { + return { + provider: (data.provider as AIProvider) ?? "openai", + 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" }; } - - 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 ─────────────────────────────────────────────────── @@ -75,9 +73,6 @@ export async function setAIProviderSettings( const current = await getAIProviderSettings(brandId); const merged = { ...current, ...settings }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - const payload = { provider: merged.provider, api_key: merged.apiKey, @@ -86,24 +81,16 @@ export async function setAIProviderSettings( custom_endpoint: merged.customEndpoint ?? null, }; - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey!), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }), - } - ); - - const data = await response.json(); - if (!response.ok || !data) { - return { success: false, error: data?.message ?? "Failed to save" }; + try { + await pool.query( + "SELECT set_ai_provider_settings($1, $2::jsonb)", + [brandId, JSON.stringify(payload)] + ); + return { success: true }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to save"; + return { success: false, error: msg }; } - - return { success: true }; } // ── Dynamic AI client factory ──────────────────────────────────────────────────── @@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{ // ── Custom integrations ───────────────────────────────────────────────────────── export async function getCustomIntegrations(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_custom_integrations`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey!), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!response.ok) return []; - const data = await response.json(); - return data ?? []; + try { + const res = await pool.query( + "SELECT * FROM get_custom_integrations($1)", + [brandId] + ); + return res.rows ?? []; + } catch { + return []; + } } export async function upsertCustomIntegration( @@ -234,25 +212,16 @@ export async function upsertCustomIntegration( if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey!), - "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 }; + try { + const res = await pool.query( + "SELECT * FROM upsert_custom_integration($1, $2::jsonb)", + [brandId, JSON.stringify(integration)] + ); + return { success: true, integrations: res.rows }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to save"; + return { success: false, error: msg }; + } } export async function deleteCustomIntegration( @@ -263,25 +232,14 @@ export async function deleteCustomIntegration( if (!adminUser) return { success: false, error: "Not authenticated" }; if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/delete_custom_integration`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey!), - "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" }; + try { + await pool.query( + "SELECT delete_custom_integration($1, $2)", + [brandId, integrationId] + ); + return { success: true }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to delete"; + return { success: false, error: msg }; } - - return { success: true }; } \ No newline at end of file diff --git a/src/actions/integrations/credentials.ts b/src/actions/integrations/credentials.ts index b91341e..f5f20b7 100644 --- a/src/actions/integrations/credentials.ts +++ b/src/actions/integrations/credentials.ts @@ -1,7 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -24,28 +24,25 @@ export type SaveCredentialsResult = // ── Resend Credentials ───────────────────────────────────────────────────────── export async function getResendCredentials(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_resend_credentials`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!response.ok) { + try { + const res = await pool.query<{ + api_key: string | null; + from_email: string | null; + from_name: string | null; + }>( + "SELECT * FROM get_resend_credentials($1)", + [brandId] + ); + const data = res.rows[0]; + if (!data) return { api_key: null, from_email: null, from_name: null }; + return { + 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 }; } - - 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( @@ -66,9 +63,6 @@ export async function saveResendCredentials( 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 const current = await getResendCredentials(brandId); const merged = { @@ -77,50 +71,39 @@ export async function saveResendCredentials( from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name, }; - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/set_resend_credentials`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: brandId, - p_credentials: merged, - }), - } - ); - - if (!response.ok) { + try { + await pool.query( + "SELECT set_resend_credentials($1, $2::jsonb)", + [brandId, JSON.stringify(merged)] + ); + return { success: true }; + } catch { return { success: false, error: "Failed to save Resend credentials" }; } - - return { success: true }; } // ── Twilio Credentials ───────────────────────────────────────────────────────── export async function getTwilioCredentials(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!response.ok) { + try { + const res = await pool.query<{ + account_sid: string | null; + auth_token: string | null; + phone_number: string | null; + }>( + "SELECT * FROM get_twilio_credentials($1)", + [brandId] + ); + const data = res.rows[0]; + if (!data) return { account_sid: null, auth_token: null, phone_number: null }; + return { + 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 }; } - - 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( @@ -141,9 +124,6 @@ export async function saveTwilioCredentials( 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 const current = await getTwilioCredentials(brandId); const merged = { @@ -152,23 +132,15 @@ export async function saveTwilioCredentials( phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number, }; - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: brandId, - p_credentials: merged, - }), - } - ); - - if (!response.ok) { + try { + await pool.query( + "SELECT set_twilio_credentials($1, $2::jsonb)", + [brandId, JSON.stringify(merged)] + ); + return { success: true }; + } catch { return { success: false, error: "Failed to save Twilio credentials" }; } - - return { success: true }; } // ── Test Connection Functions ───────────────────────────────────────────────── @@ -229,4 +201,4 @@ export async function testTwilioConnection( } catch (err) { return { ok: false, message: "Network error - please check your connection" }; } -} \ No newline at end of file +} diff --git a/src/actions/square-inventory.ts b/src/actions/square-inventory.ts index 0a3de63..ad886f2 100644 --- a/src/actions/square-inventory.ts +++ b/src/actions/square-inventory.ts @@ -2,7 +2,9 @@ import { getAdminUser } from "@/lib/admin-permissions"; 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) { return process.env.SQUARE_ENVIRONMENT === "production" @@ -110,30 +112,26 @@ export async function syncInventoryToSquare( const errors: string[] = []; const synced = 0; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - // Build product name → quantity map const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity])); try { // Fetch product details from RC const productIds = items.map((i) => i.productId); - const productsRes = await fetch( - `${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`, - { - headers: { ...svcHeaders(supabaseKey) }, - } + const rows = await withTenant(brandId, (db) => + db + .select({ id: products.id, name: products.name }) + .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"] }; } - const products: Array<{ id: string; name: string }> = await productsRes.json(); // Find Square catalog items matching product names and reduce quantity 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; if (qty <= 0) continue; @@ -177,8 +175,6 @@ export async function syncInventoryFromSquare(brandId: string): Promise const errors: string[] = []; 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 const since = settings.square_last_sync_at @@ -131,36 +129,48 @@ export async function syncOrdersFromSquare(brandId: string): Promise // Use idempotency key to avoid duplicates const idempotencyKey = `square_${payment.id}`; - const createRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/create_order_with_items`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ - p_idempotency_key: idempotencyKey, - p_customer_name: customerName, - p_customer_email: customerEmail, - p_customer_phone: "", - p_stop_id: null, // Square orders don't have RC stop_id - p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({ - id: null, // product lookup not available in this flow - quantity: li.quantity, - fulfillment: "shipping", - })), - p_subtotal: total, - p_payment_processor: "square", - p_payment_status: "paid", - p_payment_transaction_id: payment.id, - }), + // Call SECURITY DEFINER RPC create_order_with_items + let createOk = false; + let errText = ""; + try { + const rpcRes = await pool.query( + `SELECT * FROM create_order_with_items( + $1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10 + )`, + [ + idempotencyKey, + customerName, + customerEmail, + "", + null, // Square orders don't have RC stop_id + JSON.stringify( + lineItems.map((li: { name: string; quantity: number; price: number }) => ({ + id: null, // product lookup not available in this flow + quantity: li.quantity, + fulfillment: "shipping", + })) + ), + 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) { - // 409 = already exists (idempotent) + if (createOk) { synced++; } else { - const errText = await createRes.text(); - errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`); + errors.push(`Payment ${payment.id}: ${errText}`); } } catch (err) { errors.push(`Payment ${payment.id}: ${String(err)}`); diff --git a/src/actions/square-products.ts b/src/actions/square-products.ts index 9ed4cf1..c5e8075 100644 --- a/src/actions/square-products.ts +++ b/src/actions/square-products.ts @@ -3,7 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { getPaymentSettings } from "@/actions/payments"; import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export type SquareCatalogItem = { id: string; @@ -135,25 +135,8 @@ export async function syncProductsToSquare(brandId: string): Promise let synced = 0; try { - // Fetch wholesale products via RPC (avoids rc_product_id bug in direct query) - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - 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<{ + // Fetch wholesale products via SECURITY DEFINER RPC + const rpcRes = await pool.query<{ id: string; name: string; description: string | null; @@ -163,7 +146,12 @@ export async function syncProductsToSquare(brandId: string): Promise hp_sku: string | null; hp_item_id: 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 const availableProducts = products.filter((p) => p.availability === "available"); @@ -247,8 +235,6 @@ export async function syncProductsFromSquare(brandId: string): Promise( + "SELECT * FROM square_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT 10", + [brandId] ); - if (!response.ok) { - return { success: false, logs: [] }; - } - - const logs: SyncLogEntry[] = await response.json(); return { success: true, logs }; } \ No newline at end of file diff --git a/src/actions/stripe-connect.ts b/src/actions/stripe-connect.ts index bf413f9..e099f7c 100644 --- a/src/actions/stripe-connect.ts +++ b/src/actions/stripe-connect.ts @@ -1,7 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; import Stripe from "stripe"; /** @@ -19,25 +19,13 @@ export async function getStripeConnectStatus(brandId: string): Promise<{ const adminUser = await getAdminUser(); if (!adminUser) return { is_connected: false, error: "Not authenticated" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - // Get brand's payment settings - 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 }), - } + // Get brand's payment settings via SECURITY DEFINER RPC + const { rows } = await pool.query<{ stripe_user_id: string | null }>( + "SELECT * FROM get_brand_payment_settings($1)", + [brandId] ); - if (!res.ok) { - return { is_connected: false, error: "Failed to fetch payment settings" }; - } - - const data = await res.json(); - const stripeUserId = data?.stripe_user_id; + const stripeUserId = rows[0]?.stripe_user_id ?? null; if (!stripeUserId) { return { is_connected: false }; @@ -183,28 +171,17 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin success: boolean; error?: string; }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - // Save to payment_settings via RPC - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: brandId, - p_stripe_user_id: accountId, - }), - } - ); - - if (!res.ok) { - const error = await res.text(); + try { + // Save to payment_settings via SECURITY DEFINER RPC + await pool.query( + "SELECT set_stripe_connect_account($1, $2)", + [brandId, accountId] + ); + return { success: true }; + } catch (e: unknown) { + const error = e instanceof Error ? e.message : String(e); 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" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const res = await fetch( - `${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!res.ok) { + try { + // SECURITY DEFINER RPC disconnects the Stripe Connect account + await pool.query( + "SELECT disconnect_stripe_connect($1)", + [brandId] + ); + return { success: true }; + } catch { return { success: false, error: "Failed to disconnect Stripe account" }; } - - return { success: true }; } /** diff --git a/src/actions/wholesale-auth.ts b/src/actions/wholesale-auth.ts index a3b06a5..454c1ec 100644 --- a/src/actions/wholesale-auth.ts +++ b/src/actions/wholesale-auth.ts @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export type WholesaleLoginResult = | { success: true; token: string; userId: string; customerId: string } @@ -54,21 +54,17 @@ export async function wholesaleLoginAction(formData: FormData): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_company_name: params.companyName, - p_contact_name: params.contactName ?? null, - p_email: params.email, - p_phone: params.phone ?? null, - }), + try { + const { rows } = await pool.query<{ + success: boolean; + error?: string; + requires_approval?: boolean; + }>( + `SELECT * FROM register_wholesale_customer($1, $2, $3, $4, $5)`, + [ + params.brandId, + params.companyName, + params.contactName ?? null, + params.email, + params.phone ?? null, + ] + ); + const result = Array.isArray(rows) ? rows[0] : rows; + if (!result?.success) { + return { success: false, error: result?.error ?? "Registration failed." }; } - ); - - if (!response.ok) { - const err = await response.json(); - // Supabase error format: { "message": "...", "code": "...", ... } - // Our RPC error format: { "success": false, "error": "..." } - return { success: false, error: err.message ?? err.error ?? "Registration failed." }; + return { + success: true, + requiresApproval: result.requires_approval ?? false, + }; + } catch (e: unknown) { + const err = e instanceof Error ? e.message : String(e); + 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) { @@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) { if (!adminUser) return []; if (!adminUser.can_manage_orders) return []; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!response.ok) return []; - return response.json(); + try { + const { rows } = await pool.query( + "SELECT * FROM get_pending_wholesale_registrations($1)", + [brandId] + ); + return rows; + } catch { + return []; + } } export async function approveWholesaleRegistration( @@ -88,33 +70,19 @@ export async function approveWholesaleRegistration( return { success: false, error: "Not authorized to operate on this brand" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_registration_id: registrationId, - p_brand_id: brandId, - p_action: action, - }), + try { + const { rows } = await pool.query<{ success: boolean; error?: string }>( + "SELECT * FROM approve_wholesale_registration($1, $2, $3)", + [registrationId, brandId, action] + ); + const result = Array.isArray(rows) ? rows[0] : rows; + if (!result?.success) { + return { success: false, error: result?.error ?? "Failed to process registration." }; } - ); - - if (!response.ok) return { success: false, error: "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 }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." }; } - return { success: true }; } export async function getWholesaleCustomerByUser( @@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser( role: string; brand_id: string; } | null> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }), - } - ); - - if (!response.ok) return null; - const data = await response.json(); - return data ?? null; + try { + const { rows } = await pool.query( + "SELECT * FROM get_wholesale_customer_by_user($1, $2)", + [brandId, userId] + ); + return (rows[0] as { + id: string; + user_id: string; + company_name: string; + contact_name: string; + email: string; + phone: string; + account_status: string; + role: string; + brand_id: string; + } | undefined) ?? null; + } catch { + return null; + } } // Fetch a wholesale customer directly by their customer ID (used for admin preview mode) @@ -165,40 +134,41 @@ export async function getWholesaleCustomer( role: string; brand_id: string; } | null> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`, - { - headers: svcHeaders(supabaseKey), - } - ); - - if (!response.ok) return null; - const data = await response.json(); - return data?.[0] ?? null; + try { + const { rows } = await pool.query( + `SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id + FROM wholesale_customers + WHERE id = $1 + LIMIT 1`, + [customerId] + ); + return (rows[0] as { + id: string; + user_id: string; + company_name: string; + 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) { // Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = 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 (!response.ok) return []; - return response.json(); + try { + const { rows } = await pool.query( + "SELECT * FROM get_wholesale_products($1)", + [brandId] + ); + return rows; + } catch { + return []; + } } export async function submitWholesaleOrder(params: { @@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: { orderTotal?: number; 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 const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID(); @@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: { unit_price: i.unit_price, })); - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/create_wholesale_order`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_customer_id: params.customerId, - p_anticipated_pickup_date: params.anticipatedPickupDate ?? null, - p_items: itemsJson, - p_notes: params.notes ?? null, - p_checkout_session_id: checkoutSessionId, - }), - } - ); + let result: { + success?: boolean; + error?: string; + order_id?: string; + invoice_number?: string | null; + status?: string; + deposit_required?: number; + credit_limit?: number; + outstanding_balance?: number; + order_total?: number; + idempotent?: boolean; + } | null = null; - if (!response.ok) return { success: false, error: "Failed to create order." }; - const data = await response.json(); - // Normalize array response (RETURNING single row) to plain object - const result = Array.isArray(data) ? data[0] : data; + try { + const { rows } = await pool.query( + `SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`, + [ + 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 { success: false, - error: result.error ?? "Failed to create order.", - creditLimit: result.credit_limit, - outstandingBalance: result.outstanding_balance, - orderTotal: result.order_total, + error: result?.error ?? "Failed to create order.", + creditLimit: result?.credit_limit, + outstandingBalance: result?.outstanding_balance, + orderTotal: result?.order_total, }; } // Fire webhook — fire-and-forget, don't block the response enqueueWholesaleWebhookForOrderCreated( - result.order_id, + result.order_id!, params.brandId, params.customerId, - result.invoice_number, + result.invoice_number ?? null, Number(result.deposit_required) || 0 ).catch(() => {}); return { success: true, orderId: result.order_id, - invoiceNumber: result.invoice_number, + invoiceNumber: result.invoice_number ?? undefined, status: result.status, depositRequired: result.deposit_required, 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) { try { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; - await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_event_type: "order_created", - p_order_id: orderId, - p_brand_id: brandId, - p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }, - }), - }); + await pool.query( + "SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)", + [ + "order_created", + orderId, + brandId, + JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }), + ] + ); } catch (_) {} } @@ -344,23 +314,15 @@ export type WholesalePricingOverride = { }; export async function getWholesaleCustomerPricing(customerId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_customer_id: customerId }), - } - ); - - if (!response.ok) return []; - return response.json(); + try { + const { rows } = await pool.query( + "SELECT * FROM get_wholesale_customer_pricing($1)", + [customerId] + ); + return rows as WholesalePricingOverride[]; + } catch { + return []; + } } export async function upsertWholesaleCustomerPricing(params: { @@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: { productId: string; customUnitPrice: number; }): Promise<{ success: boolean; error?: string }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`, - { - method: "POST", - headers: { - ...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 }; + try { + await pool.query( + "SELECT upsert_wholesale_customer_pricing($1, $2, $3)", + [params.customerId, params.productId, params.customUnitPrice] + ); + return { success: true }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" }; + } } export async function deleteWholesaleCustomerPricing(params: { customerId: string; productId: string; }): Promise<{ success: boolean; error?: string }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`, - { - method: "POST", - headers: { - ...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 }; + try { + await pool.query( + "SELECT delete_wholesale_customer_pricing($1, $2)", + [params.customerId, params.productId] + ); + return { success: true }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" }; + } } export async function getWholesaleCustomerOrders(customerId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_customer_id: customerId }), - } - ); - - if (!response.ok) return []; - return response.json(); + try { + const { rows } = await pool.query( + "SELECT * FROM get_wholesale_customer_orders($1)", + [customerId] + ); + return rows as WholesaleCustomerOrder[]; + } catch { + return []; + } } \ No newline at end of file diff --git a/src/actions/wholesale.ts b/src/actions/wholesale.ts index ee32497..f8d8beb 100644 --- a/src/actions/wholesale.ts +++ b/src/actions/wholesale.ts @@ -2,7 +2,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export type WholesaleOrder = { id: string; @@ -190,23 +190,15 @@ export async function getWholesaleOrders(brandId?: string): Promise( + "SELECT * FROM get_wholesale_orders($1)", + [bid] + ); + return rows; + } catch { + return []; + } } export async function getWholesalePickupOrders(brandId?: string): Promise { @@ -214,23 +206,15 @@ export async function getWholesalePickupOrders(brandId?: string): Promise( + "SELECT * FROM get_wholesale_pickup_orders($1)", + [bid] + ); + return rows; + } catch { + return []; + } } export async function getWholesaleDashboardStats(brandId?: string): Promise { @@ -262,22 +246,17 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/mark_wholesale_order_fulfilled`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_order_id: orderId, p_by: adminUser.user_id }), + try { + const { rows } = await pool.query( + "SELECT * FROM mark_wholesale_order_fulfilled($1, $2)", + [orderId, adminUser.user_id] + ); + if (!rows || rows.length === 0) { + return { success: false, error: "Failed to mark fulfilled" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to mark fulfilled" }; + } catch { + return { success: false, error: "Failed to mark fulfilled" }; + } enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {}); @@ -285,18 +264,17 @@ export async function markWholesaleOrderFulfilled(orderId: string, brandId?: str } async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; - await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_event_type: "order_fulfilled", - p_order_id: orderId, - p_brand_id: brandId ?? null, - p_payload: { order_id: orderId }, - }), - }); + try { + await pool.query( + "SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)", + [ + "order_fulfilled", + orderId, + brandId ?? null, + JSON.stringify({ order_id: orderId }), + ] + ); + } catch (_) {} } export async function updateWholesaleOrderStatus( @@ -311,23 +289,15 @@ export async function updateWholesaleOrderStatus( const { error } = await enforceBrandScope(adminUser, brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/update_wholesale_order_status`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_order_id: orderId, p_status: status }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to update order status" }; - return { success: true }; + try { + await pool.query( + "SELECT * FROM update_wholesale_order_status($1, $2)", + [orderId, status] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to update order status" }; + } } export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { @@ -338,23 +308,15 @@ export async function deleteWholesaleOrder(orderId: string, brandId?: string): P const { error } = await enforceBrandScope(adminUser, brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/delete_wholesale_order`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_order_id: orderId }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to delete order" }; - return { success: true }; + try { + await pool.query( + "SELECT * FROM delete_wholesale_order($1)", + [orderId] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to delete order" }; + } } export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { @@ -365,25 +327,19 @@ export async function deleteWholesaleCustomer(customerId: string, brandId?: stri const { error } = await enforceBrandScope(adminUser, brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_customer_id: customerId }), + try { + const { rows } = await pool.query<{ success: boolean; error?: string }>( + "SELECT * FROM delete_wholesale_customer($1)", + [customerId] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + if (!data?.success) { + return { success: false, error: data?.error ?? "Delete failed" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to delete customer" }; - const data = await response.json(); - if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; - return { success: true }; + return { success: true }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" }; + } } export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> { @@ -394,25 +350,19 @@ export async function deleteWholesaleProduct(productId: string, brandId?: string const { error } = await enforceBrandScope(adminUser, brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/delete_wholesale_product`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_product_id: productId }), + try { + const { rows } = await pool.query<{ success: boolean; error?: string }>( + "SELECT * FROM delete_wholesale_product($1)", + [productId] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + if (!data?.success) { + return { success: false, error: data?.error ?? "Delete failed" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to delete product" }; - const data = await response.json(); - if (!data.success) return { success: false, error: data.error ?? "Delete failed" }; - return { success: true }; + return { success: true }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" }; + } } // ── Customers ──────────────────────────────────────────────────────────────── @@ -422,23 +372,15 @@ export async function getWholesaleCustomers(brandId?: string): Promise( + "SELECT * FROM get_wholesale_customers($1)", + [bid] + ); + return rows; + } catch { + return []; + } } export async function saveWholesaleCustomer(params: { @@ -464,39 +406,31 @@ export async function saveWholesaleCustomer(params: { const { error } = await enforceBrandScope(adminUser, params.brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_user_id: params.userId ?? null, - p_company_name: params.companyName ?? null, - p_contact_name: params.contactName ?? null, - p_email: params.email ?? null, - p_phone: params.phone ?? null, - p_account_status: params.accountStatus ?? "active", - p_credit_limit: params.creditLimit ?? 0, - p_deposits_enabled: params.depositsEnabled ?? false, - p_deposit_threshold: params.depositThreshold ?? null, - p_deposit_percentage: params.depositPercentage ?? null, - p_order_email: params.orderEmail ?? null, - p_invoice_email: params.invoiceEmail ?? null, - p_admin_notes: params.adminNotes ?? null, - }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to save customer" }; - const data = await response.json(); - return { success: true, id: data.id }; + try { + const { rows } = await pool.query<{ id: string }>( + "SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + [ + params.brandId, + params.userId ?? null, + params.companyName ?? null, + params.contactName ?? null, + params.email ?? null, + params.phone ?? null, + params.accountStatus ?? "active", + params.creditLimit ?? 0, + params.depositsEnabled ?? false, + params.depositThreshold ?? null, + params.depositPercentage ?? null, + params.orderEmail ?? null, + params.invoiceEmail ?? null, + params.adminNotes ?? null, + ] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + return { success: true, id: data?.id }; + } catch { + return { success: false, error: "Failed to save customer" }; + } } // ── Products ───────────────────────────────────────────────────────────────── @@ -506,23 +440,15 @@ export async function getWholesaleProducts(brandId?: string): Promise( + "SELECT * FROM get_wholesale_products($1)", + [bid] + ); + return rows; + } catch { + return []; + } } export async function saveWholesaleProduct(params: { @@ -551,42 +477,34 @@ export async function saveWholesaleProduct(params: { const { error } = await enforceBrandScope(adminUser, params.brandId); if (error) return { success: false, error }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_product`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_id: params.id ?? null, - p_name: params.name, - p_description: params.description ?? null, - p_unit_type: params.unitType ?? "each", - p_availability: params.availability ?? "unavailable", - p_qty_available: params.qtyAvailable ?? 0, - p_price_tiers: JSON.stringify(params.priceTiers ?? []), - p_hp_sku: params.hpSku ?? null, - p_hp_item_id: params.hpItemId ?? null, - p_internal_notes: params.internalNotes ?? null, - p_handling_instructions: params.handlingInstructions ?? null, - p_storage_warning: params.storageWarning ?? null, - p_product_label: params.productLabel ?? null, - p_pack_style: params.packStyle ?? null, - p_container_type: params.containerType ?? null, - p_default_pickup_location: params.defaultPickupLocation ?? null, - }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to save product" }; - const data = await response.json(); - return { success: true, id: data.id }; + try { + const { rows } = await pool.query<{ id: string }>( + "SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)", + [ + params.brandId, + params.id ?? null, + params.name, + params.description ?? null, + params.unitType ?? "each", + params.availability ?? "unavailable", + params.qtyAvailable ?? 0, + JSON.stringify(params.priceTiers ?? []), + params.hpSku ?? null, + params.hpItemId ?? null, + params.internalNotes ?? null, + params.handlingInstructions ?? null, + params.storageWarning ?? null, + params.productLabel ?? null, + params.packStyle ?? null, + params.containerType ?? null, + params.defaultPickupLocation ?? null, + ] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + return { success: true, id: data?.id }; + } catch { + return { success: false, error: "Failed to save product" }; + } } // ── Settings ───────────────────────────────────────────────────────────────── @@ -599,24 +517,15 @@ export async function getWholesaleSettings(brandId?: string): Promise( + "SELECT * FROM get_wholesale_settings($1)", + [bid] + ); + return rows[0] ?? null; + } catch { + return null; + } } export async function saveWholesaleSettings(params: { @@ -641,40 +550,32 @@ export async function saveWholesaleSettings(params: { if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_settings`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_require_approval: params.requireApproval ?? null, - p_min_order_amount: params.minOrderAmount ?? null, - p_online_payment_enabled: params.onlinePaymentEnabled ?? null, - p_wholesale_enabled: params.wholesaleEnabled ?? null, - p_pickup_location: params.pickupLocation ?? null, - p_fob_location: params.fobLocation ?? null, - p_from_email: params.fromEmail ?? null, - p_invoice_business_name: params.invoiceBusinessName ?? null, - p_invoice_business_address: params.invoiceBusinessAddress ?? null, - p_invoice_business_phone: params.invoiceBusinessPhone ?? null, - p_invoice_business_email: params.invoiceBusinessEmail ?? null, - p_invoice_business_website: params.invoiceBusinessWebsite ?? null, - p_notification_email: params.notificationEmail ?? null, - p_notification_recipients: params.notificationRecipients ?? null, - p_square_sync_enabled: params.squareSyncEnabled ?? null, - }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to save settings" }; - return { success: true }; + try { + await pool.query( + "SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)", + [ + params.brandId, + params.requireApproval ?? null, + params.minOrderAmount ?? null, + params.onlinePaymentEnabled ?? null, + params.wholesaleEnabled ?? null, + params.pickupLocation ?? null, + params.fobLocation ?? null, + params.fromEmail ?? null, + params.invoiceBusinessName ?? null, + params.invoiceBusinessAddress ?? null, + params.invoiceBusinessPhone ?? null, + params.invoiceBusinessEmail ?? null, + params.invoiceBusinessWebsite ?? null, + params.notificationEmail ?? null, + params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null, + params.squareSyncEnabled ?? null, + ] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to save settings" }; + } } // ── Deposits ───────────────────────────────────────────────────────────────── @@ -689,51 +590,39 @@ export async function recordWholesaleDeposit( if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/record_wholesale_deposit`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_order_id: orderId, - p_amount: amount, - p_method: method, - p_reference: reference ?? null, - p_recorded_by: adminUser.user_id, - p_brand_id: await getActiveBrandId(adminUser), - }), + const activeBrandId = await getActiveBrandId(adminUser); + let data: { success?: boolean; error?: string } | null = null; + try { + const { rows } = await pool.query<{ success: boolean; error?: string }>( + "SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)", + [orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId] + ); + data = Array.isArray(rows) ? rows[0] : rows; + if (!data?.success) { + return { success: false, error: data?.error ?? "Failed to record deposit" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to record deposit" }; - const data = await response.json(); - if (!data?.success) return { success: false, error: data?.error ?? "Failed to record deposit" }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" }; + } // Fire webhook — fire-and-forget - enqueueWholesaleWebhookForDepositRecorded(orderId, amount, (await getActiveBrandId(adminUser)) ?? undefined).catch(() => {}); + enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {}); return { success: true }; } async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!; - await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_event_type: "deposit_recorded", - p_order_id: orderId, - p_brand_id: brandId ?? null, - p_payload: { order_id: orderId, amount }, - }), - }); + try { + await pool.query( + "SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)", + [ + "deposit_recorded", + orderId, + brandId ?? null, + JSON.stringify({ order_id: orderId, amount }), + ] + ); + } catch (_) {} } // ── Bulk Actions ────────────────────────────────────────────────────────────── @@ -745,29 +634,19 @@ export async function bulkFulfillWholesaleOrders( if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/bulk_fulfill_wholesale_orders`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_order_ids: orderIds, - p_by: adminUser.user_id, - p_brand_id: await getActiveBrandId(adminUser), - }), + try { + const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>( + "SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)", + [orderIds, adminUser.user_id, await getActiveBrandId(adminUser)] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + if (!data?.success) { + return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to bulk fulfill orders" }; - const data = await response.json(); - if (!data?.success) return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" }; - return { success: true, count: data.count }; + return { success: true, count: data.count }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" }; + } } export async function bulkRecordWholesaleDeposit( @@ -780,32 +659,19 @@ export async function bulkRecordWholesaleDeposit( if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/bulk_record_wholesale_deposit`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_order_ids: orderIds, - p_amount: amount, - p_method: method, - p_reference: reference ?? null, - p_recorded_by: adminUser.user_id, - p_brand_id: await getActiveBrandId(adminUser), - }), + try { + const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>( + "SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)", + [orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + if (!data?.success) { + return { success: false, error: data?.error ?? "Failed to bulk record deposits" }; } - ); - - if (!response.ok) return { success: false, error: "Failed to bulk record deposits" }; - const data = await response.json(); - if (!data?.success) return { success: false, error: data?.error ?? "Failed to bulk record deposits" }; - return { success: true, count: data.count }; + return { success: true, count: data.count }; + } catch (e: unknown) { + return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" }; + } } // ── Notifications ───────────────────────────────────────────────────────────── @@ -830,23 +696,15 @@ export type WholesaleNotification = { export async function getWholesaleNotificationStats( brandId: string ): Promise<{ pending: number; sent: number; failed: number; total: number }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_notification_stats`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!response.ok) return { pending: 0, sent: 0, failed: 0, total: 0 }; - return response.json(); + try { + const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>( + "SELECT * FROM get_wholesale_notification_stats($1)", + [brandId] + ); + return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 }; + } catch { + return { pending: 0, sent: 0, failed: 0, total: 0 }; + } } export async function getWholesalePendingNotifications( @@ -857,23 +715,15 @@ export async function getWholesalePendingNotifications( if (!adminUser) return []; if (!adminUser.can_manage_orders) return []; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = 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: brandId, p_limit: limit }), - } - ); - - if (!response.ok) return []; - return response.json(); + try { + const { rows } = await pool.query( + "SELECT * FROM get_wholesale_pending_notifications($1, $2)", + [brandId, limit] + ); + return rows; + } catch { + return []; + } } export async function markWholesaleNotificationSent( @@ -884,22 +734,15 @@ export async function markWholesaleNotificationSent( if (!adminUser) return { success: false }; if (!adminUser.can_manage_orders) return { success: false }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/mark_wholesale_notification_sent`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ p_notification_id: notificationId, p_error: error ?? null }), - } - ); - - return { success: response.ok }; + try { + await pool.query( + "SELECT mark_wholesale_notification_sent($1, $2)", + [notificationId, error ?? null] + ); + return { success: true }; + } catch { + return { success: false }; + } } export async function enqueueWholesaleNotification(params: { @@ -913,33 +756,25 @@ export async function enqueueWholesaleNotification(params: { bodyHtml?: string; bodyText?: string; }): Promise<{ success: boolean; error?: string }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: params.brandId, - p_customer_id: params.customerId, - p_order_id: params.orderId, - p_type: params.type, - p_email_to: params.emailTo, - p_email_cc: params.emailCc ?? null, - p_subject: params.subject, - p_body_html: params.bodyHtml ?? null, - p_body_text: params.bodyText ?? null, - }), - } - ); - - if (!response.ok) return { success: false, error: "Failed to enqueue notification" }; - return { success: true }; + try { + await pool.query( + "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + params.brandId, + params.customerId, + params.orderId, + params.type, + params.emailTo, + params.emailCc ?? null, + params.subject, + params.bodyHtml ?? null, + params.bodyText ?? null, + ] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to enqueue notification" }; + } } // ── Webhooks ───────────────────────────────────────────────────────────────── @@ -955,19 +790,15 @@ export type WebhookSettings = { }; export async function getWebhookSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/wholesale_webhook_settings?brand_id=eq.${brandId}&select=*`, - { - headers: svcHeaders(supabaseKey), - } - ); - - if (!response.ok) return null; - const data = await response.json(); - return data?.[0] ?? null; + try { + const { rows } = await pool.query( + "SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1", + [brandId] + ); + return rows[0] ?? null; + } catch { + return null; + } } export async function saveWebhookSettings(params: { @@ -980,31 +811,22 @@ export async function saveWebhookSettings(params: { if (!adminUser) return { success: false, error: "Not authenticated" }; if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - const body: Record = { - brand_id: params.brandId, - url: params.url ?? "", - secret: params.secret ?? "", - enabled: params.enabled ?? false, - }; - - // Upsert using service role key (RLS blocks direct writes) - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_wholesale_webhook_settings`, - { - method: "POST", - headers: { - ...svcHeaders(serviceKey), - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - } - ); - - if (!response.ok) return { success: false, error: "Failed to save webhook settings" }; - return { success: true }; + try { + await pool.query( + "SELECT upsert_wholesale_webhook_settings($1::jsonb)", + [ + JSON.stringify({ + brand_id: params.brandId, + url: params.url ?? "", + secret: params.secret ?? "", + enabled: params.enabled ?? false, + }), + ] + ); + return { success: true }; + } catch { + return { success: false, error: "Failed to save webhook settings" }; + } } export async function enqueueWholesaleWebhook( @@ -1013,29 +835,16 @@ export async function enqueueWholesaleWebhook( payload: Record | null = null, brandId?: string ): Promise<{ success: boolean; logId?: string }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const response = await fetch( - `${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, - { - method: "POST", - headers: { - ...svcHeaders(supabaseKey), - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_event_type: eventType, - p_order_id: orderId, - p_payload: payload, - p_brand_id: brandId ?? null, - }), - } - ); - - if (!response.ok) return { success: false }; - const data = await response.json(); - return { success: true, logId: data }; + try { + const { rows } = await pool.query<{ id: string }>( + "SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)", + [eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null] + ); + const data = Array.isArray(rows) ? rows[0] : rows; + return { success: true, logId: data?.id }; + } catch { + return { success: false }; + } } export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const res = await fetch( - `${supabaseUrl}/rest/v1/wholesale_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=${limit}`, - { - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - } - ); - if (!res.ok) return []; - return res.json(); + try { + const { rows } = await pool.query<{ + id: string; + event_type: string; + order_id: string | null; + status: string; + attempts: number; + created_at: string; + response: string | null; + }>( + "SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2", + [brandId, limit] + ); + return rows; + } catch { + return []; + } } \ No newline at end of file diff --git a/src/app/api/square/oauth/callback/route.ts b/src/app/api/square/oauth/callback/route.ts index d66bb46..e0eb36d 100644 --- a/src/app/api/square/oauth/callback/route.ts +++ b/src/app/api/square/oauth/callback/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; import { cookies } from "next/headers"; 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 - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - + // Store token + location_id in payment_settings via SECURITY DEFINER RPC try { - const upsertResponse = await fetch( - `${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" }, - body: JSON.stringify({ - p_brand_id: brandId, - p_provider: "square", - p_square_access_token: accessToken, - p_square_location_id: locationId, - }), - } + await pool.query( + "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + brandId, + "square", + null, // stripe_publishable_key + null, // stripe_secret_key + null, // stripe_user_id + accessToken, + 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) { return NextResponse.redirect( new URL("/admin/settings/payments?error=square_token_save_error", request.url) diff --git a/src/app/api/square/process-queue/route.ts b/src/app/api/square/process-queue/route.ts index 8281e45..d5c13f6 100644 --- a/src/app/api/square/process-queue/route.ts +++ b/src/app/api/square/process-queue/route.ts @@ -3,7 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products"; import { syncOrdersFromSquare } from "@/actions/square-orders"; import { getPaymentSettings } from "@/actions/payments"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export async function POST(request: Request) { const adminUser = await getAdminUser(); @@ -58,19 +58,22 @@ export async function POST(request: Request) { } // 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; - await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: brandId, - p_square_sync_enabled: true, - p_square_inventory_mode: settings?.square_inventory_mode ?? "none", - }), - }); + await pool.query( + "SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + brandId, + null, // provider (not changed) + null, // stripe_publishable_key + 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({ success: syncErrors.length === 0, diff --git a/src/app/api/square/sync/route.ts b/src/app/api/square/sync/route.ts index 27d04fa..149ce69 100644 --- a/src/app/api/square/sync/route.ts +++ b/src/app/api/square/sync/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { syncProductsToSquare } from "@/actions/square-products"; import { getAdminUser } from "@/lib/admin-permissions"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; export const dynamic = "force-dynamic"; @@ -18,24 +18,19 @@ export async function GET(request: Request) { return NextResponse.json({ error: "brand_id required" }, { status: 400 }); } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - const claimRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/claim_square_sync_queue`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" }, - body: JSON.stringify({ p_brand_id: brandId }), - } - ); - - if (!claimRes.ok) { - const errText = await claimRes.text(); + // Claim a pending sync entry from the queue via SECURITY DEFINER RPC + let entries: Array<{ id: string; brand_id: string }> = []; + try { + const { rows } = await pool.query<{ id: string; brand_id: string }>( + "SELECT * FROM claim_square_sync_queue($1)", + [brandId] + ); + entries = rows; + } catch (e: unknown) { + const errText = e instanceof Error ? e.message : String(e); return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 }); } - const entries = await claimRes.json(); if (!entries || entries.length === 0 || entries[0] === null) { 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 lastError = result.errors.length > 0 ? result.errors[0] : null; - await fetch( - `${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: entry.brand_id, - p_error: lastError, - }), - } + await pool.query( + "SELECT update_square_sync_timestamp($1, $2)", + [entry.brand_id, lastError] ); - await fetch( - `${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${entry.id}`, - { - method: "PATCH", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - status: newStatus, - processed_at: new Date().toISOString(), - last_error: lastError, - }), - } + await pool.query( + "UPDATE square_sync_queue SET status = $1, processed_at = $2, last_error = $3 WHERE id = $4", + [newStatus, new Date().toISOString(), lastError, entry.id] ); return NextResponse.json({ diff --git a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts index b617baf..10cf648 100644 --- a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts +++ b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts @@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; import { getAdminUser } from "@/lib/admin-permissions"; 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( req: NextRequest, @@ -11,78 +34,40 @@ export async function GET( const { orderId } = await params; 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 ──────────────────────────────────────────── if (token) { - // Look up order by ID + token. Falls back to a direct select so the - // invoice_token is checked server-side (not exposed to the client). - const orderRes = await fetch( - `${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`, - { - headers: svcHeaders(supabaseKey), - } + // Look up order by ID + token. The invoice_token is checked server-side. + const tokenRes = await pool.query<{ brand_id: string }>( + "SELECT brand_id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1", + [orderId, token] ); - if (!orderRes.ok) { - return new NextResponse("Not found", { status: 404 }); - } - - const orders = await orderRes.json(); - if (!orders || orders.length === 0) { + if (tokenRes.rows.length === 0) { return new NextResponse("Not found", { status: 404 }); } // 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 - const fullRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + const fullRes = await pool.query( + "SELECT * FROM get_wholesale_orders($1)", + [brandId] ); - if (!fullRes.ok) { - 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 allOrders = fullRes.rows; const order = allOrders.find(o => o.id === orderId); if (!order) { return new NextResponse("Not found", { status: 404 }); } // Fetch brand-specific settings for invoice header - const settingsRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + const settingsRes = await pool.query( + "SELECT * FROM get_wholesale_settings($1)", + [brandId] ); - const settingsData = await settingsRes.json(); - const settings = settingsData ?? {}; + const settings = settingsRes.rows[0] ?? {}; const pdfBytes = await buildInvoicePdf(order, settings); return new NextResponse(Buffer.from(pdfBytes), { @@ -103,50 +88,23 @@ export async function GET( } // Fetch the order directly by ID with brand scoping - const orderRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? undefined }), - } + const orderRes = await pool.query( + "SELECT * FROM get_wholesale_orders($1)", + [adminUser.brand_id ?? null] ); - if (!orderRes.ok) { - 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 orders = orderRes.rows; const order = orders.find(o => o.id === orderId); if (!order) { return new NextResponse("Order not found", { status: 404 }); } - const settingsRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: order.brand_id }), - } + const settingsRes = await pool.query( + "SELECT * FROM get_wholesale_settings($1)", + [order.brand_id] ); - const settingsData = await settingsRes.json(); - const settings = settingsData ?? {}; + const settings = settingsRes.rows[0] ?? {}; const pdfBytes = await buildInvoicePdf(order, settings); @@ -158,27 +116,7 @@ export async function GET( }); } -async function buildInvoicePdf( - 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; - } -) { +async function buildInvoicePdf(order: OrderRow, settings: InvoiceSettings) { const pdfDoc = await PDFDocument.create(); const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); diff --git a/src/app/api/wholesale/invoice/[orderId]/route.ts b/src/app/api/wholesale/invoice/[orderId]/route.ts index 4df205f..99fc6b4 100644 --- a/src/app/api/wholesale/invoice/[orderId]/route.ts +++ b/src/app/api/wholesale/invoice/[orderId]/route.ts @@ -1,6 +1,22 @@ import { NextRequest, NextResponse } from "next/server"; 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( req: NextRequest, @@ -9,24 +25,17 @@ export async function GET( const { orderId } = await params; const token = req.nextUrl.searchParams.get("token"); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - if (!supabaseUrl || !supabaseKey) { + if (!process.env.DATABASE_URL) { return new NextResponse("Server misconfiguration", { status: 500 }); } // ── Token-gated download (customer portal) ────────────────────────────────── if (token) { - const orderRes = await fetch( - `${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`, - { headers: svcHeaders(supabaseKey) } + const tokenRes = await pool.query<{ id: string }>( + "SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1", + [orderId, token] ); - if (!orderRes.ok || orderRes.status === 204) { - return new NextResponse("Not found", { status: 404 }); - } - const tokenOrders = await orderRes.json(); - if (!tokenOrders || tokenOrders.length === 0) { + if (tokenRes.rows.length === 0) { return new NextResponse("Not found", { status: 404 }); } } @@ -36,64 +45,38 @@ export async function GET( let brandId = "00000000-0000-0000-0000-000000000000"; // First try direct order lookup to get brand_id - const directRes = await fetch( - `${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`, - { headers: svcHeaders(supabaseKey) } + const directRes = await pool.query<{ brand_id: string }>( + "SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1", + [orderId] ); - if (directRes.ok) { - const direct = await directRes.json(); - if (direct && direct.length > 0) { - brandId = direct[0].brand_id; - } + if (directRes.rows.length > 0) { + brandId = directRes.rows[0].brand_id; } - const orderRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: brandId }), - } + const orderRes = await pool.query( + "SELECT * FROM get_wholesale_orders($1)", + [brandId] ); - if (!orderRes.ok) { - 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 allOrders = orderRes.rows; const order = allOrders.find(o => o.id === orderId); if (!order) { return new NextResponse("Order not found", { status: 404 }); } // Fetch settings for brand header - const settingsRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: order.brand_id }), - } + const settingsRes = await pool.query<{ + invoice_business_name?: string; + invoice_business_address?: string; + invoice_business_phone?: string; + invoice_business_email?: string; + invoice_business_website?: string; + }>( + "SELECT * FROM get_wholesale_settings($1)", + [order.brand_id] ); - const settingsData = await settingsRes.json(); - const settings = settingsData ?? {}; + const settings = settingsRes.rows[0] ?? {}; const pdfBytes = await buildInvoicePdf(order, settings); diff --git a/src/app/api/wholesale/notifications/pickup-reminder/route.ts b/src/app/api/wholesale/notifications/pickup-reminder/route.ts index cfcf418..9d22e2b 100644 --- a/src/app/api/wholesale/notifications/pickup-reminder/route.ts +++ b/src/app/api/wholesale/notifications/pickup-reminder/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; // POST /api/wholesale/notifications/pickup-reminder // 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 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 - const ordersRes = await fetch( - `${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<{ + const ordersRes = await pool.query<{ id: string; brand_id: string; customer_id: string; @@ -37,12 +21,16 @@ export async function POST() { notification_email: string | null; from_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 }); } + const overdueOrders = ordersRes.rows; + let enqueued = 0; let skipped = 0; @@ -55,33 +43,28 @@ export async function POST() { const adminEmail = order.notification_email ?? order.from_email ?? order.invoice_business_email; - const enqueueRes = await fetch( - `${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, - { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: order.brand_id, - p_customer_id: order.customer_id, - p_order_id: order.id, - p_type: "unclaimed_pickup", - p_email_to: order.customer_email, - p_email_cc: adminEmail, - p_subject: `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`, - p_body_html: ` + try { + await pool.query( + "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + order.brand_id, + order.customer_id, + order.id, + "unclaimed_pickup", + order.customer_email, + adminEmail, + `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`, + `

Order Overdue for Pickup

Your order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not yet been picked up.

${order.pickup_location ? `

Pickup location: ${order.pickup_location}

` : ""}

Please arrange pickup as soon as possible. Contact us if you have any questions.

`, - 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.`, - }), - } - ); - - if (enqueueRes.ok) { + `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.`, + ] + ); enqueued++; - } else { + } catch { skipped++; } } diff --git a/src/app/api/wholesale/notifications/send/route.ts b/src/app/api/wholesale/notifications/send/route.ts index e936f3d..b4888dd 100644 --- a/src/app/api/wholesale/notifications/send/route.ts +++ b/src/app/api/wholesale/notifications/send/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale"; -import { svcHeaders } from "@/lib/svc-headers"; +import { markWholesaleNotificationSent } from "@/actions/wholesale"; +import { pool } from "@/lib/db"; import type { NotificationRecipient } from "@/actions/wholesale"; // POST /api/wholesale/notifications/send @@ -18,23 +18,7 @@ export async function POST() { ); } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - 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<{ + const pendingRes = await pool.query<{ id: string; type: string; email_to: string; @@ -46,12 +30,17 @@ export async function POST() { order_id: string | null; customer_id: string; 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 }); } + const notifications = pendingRes.rows; + // Prefetch settings for each unique brand so we can resolve notification_recipients const brandIds = [...new Set(notifications.map(n => n.brand_id))]; const brandSettingsMap: Record = {}; await Promise.all(brandIds.map(async (bid) => { - const r = await fetch(`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ p_brand_id: bid }), - }); - if (r.ok) { - const data = await r.json(); + const r = await pool.query<{ + notification_recipients: NotificationRecipient[]; + notification_email: string | null; + from_email: string | null; + invoice_business_email: string | null; + }>( + "SELECT * FROM get_wholesale_settings($1)", + [bid] + ); + if (r.rows.length > 0) { + const data = r.rows[0]; brandSettingsMap[bid] = { notification_recipients: data?.notification_recipients ?? [], 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); // Log a separate notification entry for audit trail - await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, { - method: "POST", - headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" }, - body: JSON.stringify({ - p_brand_id: n.brand_id, - p_customer_id: n.customer_id, - p_order_id: n.order_id ?? null, - p_type: n.type, - p_email_to: recipient.email, - p_email_cc: null, - p_subject: n.subject, - p_body_html: n.body_html, - p_body_text: n.body_text, - }), - }); + await pool.query( + "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + n.brand_id, + n.customer_id, + n.order_id ?? null, + n.type, + recipient.email, + null, + n.subject, + n.body_html, + n.body_text, + ] + ); if (ok) sent++; else failed++; } @@ -179,8 +171,12 @@ async function sendOneEmail( } 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 { - 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", headers: { "Content-Type": "application/json" }, }); diff --git a/src/app/api/wholesale/webhooks/dispatch/route.ts b/src/app/api/wholesale/webhooks/dispatch/route.ts index bf6cfce..90822ba 100644 --- a/src/app/api/wholesale/webhooks/dispatch/route.ts +++ b/src/app/api/wholesale/webhooks/dispatch/route.ts @@ -1,36 +1,17 @@ import { NextResponse } from "next/server"; import crypto from "crypto"; -import { svcHeaders } from "@/lib/svc-headers"; +import { pool } from "@/lib/db"; // POST /api/wholesale/webhooks/dispatch // 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. export async function POST() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - - if (!supabaseUrl || !serviceRoleKey) { + if (!process.env.DATABASE_URL) { return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 }); } // Fetch pending webhooks - const pendingRes = await fetch( - `${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<{ + const pendingRes = await pool.query<{ id: string; brand_id: string; event_type: string; @@ -39,12 +20,16 @@ export async function POST() { attempts: number; url: 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 }); } + const pending = pendingRes.rows; let dispatched = 0; for (const webhook of pending) { @@ -71,40 +56,40 @@ export async function POST() { const responseText = await res.text().catch(() => ""); 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++; } 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) { 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 }); } -async function markSent(logId: string, key: string, url: string, response: string) { - await fetch( - `${url}/rest/v1/rpc/mark_webhook_sent`, - { - method: "POST", - headers: { ...svcHeaders(key), "Content-Type": "application/json" }, - body: JSON.stringify({ p_log_id: logId, p_response: response }), - } - ); +async function markSent(logId: string, response: string) { + try { + await pool.query( + "SELECT mark_webhook_sent($1, $2)", + [logId, response] + ); + } catch { + // best-effort + } } -async function markFailed(logId: string, key: string, url: string, response: string) { - await fetch( - `${url}/rest/v1/rpc/mark_webhook_failed`, - { - method: "POST", - headers: { ...svcHeaders(key), "Content-Type": "application/json" }, - body: JSON.stringify({ p_log_id: logId, p_response: response }), - } - ); +async function markFailed(logId: string, response: string) { + try { + await pool.query( + "SELECT mark_webhook_failed($1, $2)", + [logId, response] + ); + } catch { + // best-effort + } } export async function GET() { diff --git a/src/lib/billing.ts b/src/lib/billing.ts index 93c4064..2671701 100644 --- a/src/lib/billing.ts +++ b/src/lib/billing.ts @@ -5,10 +5,7 @@ import "server-only"; import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing"; -import { svcHeaders } from "@/lib/svc-headers"; - -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!; +import { pool } from "@/lib/db"; // ── Subscription status types ────────────────────────────────────────────────── @@ -29,22 +26,18 @@ export interface BrandSubscription { plan_tier: PlanTierKey; } -// ── RPC call helper ─────────────────────────────────────────────────────────── +// ── RPC call helper (raw SQL via pg pool) ───────────────────────────────────── -async function rpc( +async function rpc>( fn: string, - body: Record + params: ReadonlyArray ): Promise { - const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, { - method: "POST", - headers: { ...svcHeaders(SERVICE_KEY), "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const err = await res.text(); - throw new Error(`RPC ${fn} failed: ${err}`); - } - return res.json() as Promise; + const placeholders = params.map((_, i) => `$${i + 1}`).join(", "); + const { rows } = await pool.query>( + `SELECT * FROM ${fn}(${placeholders})`, + params as unknown[] + ); + return rows as unknown as T; } // ── Feature sync ───────────────────────────────────────────────────────────── @@ -75,7 +68,7 @@ export async function syncSubscriptionFeatures( if (tierItem) { const newTier = priceToTier[tierItem.priceId]; 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; const item = subscriptionItems.find((i) => i.priceId === priceId); 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 }); // Get brand's Stripe customer - const brands = await rpc>( + const brandRows = await rpc>( "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; 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})`); // 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; if (existingSubId) { @@ -155,12 +148,12 @@ export async function createOrUpdateSubscription( // Save subscription ID to brand const subData = subscription as unknown as { id: string; status: string; current_period_end: number }; - await rpc("set_brand_subscription", { - p_brand_id: brandId, - p_subscription_id: subData.id, - p_status: subData.status, - p_current_period_end: new Date(subData.current_period_end * 1000).toISOString(), - }); + await rpc("set_brand_subscription", [ + brandId, + subData.id, + subData.status, + new Date(subData.current_period_end * 1000).toISOString(), + ]); const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | 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; if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured"); - const brandData = await rpc("get_brand_subscription", { p_brand_id: brandId }); - const brand = brandData[0] as unknown as BrandSubscription | undefined; + const brandRows = await rpc("get_brand_subscription", [brandId]); + const brand = brandRows[0]; if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel"); const Stripe = (await import("stripe")).default; @@ -200,21 +193,21 @@ export async function cancelBrandSubscription( // Disable the feature flag const addonKey = getAddonKeyFromPriceKey(priceKey); 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 { // Cancel entire subscription await stripe.subscriptions.cancel(brand.stripe_subscription_id); - await rpc("set_brand_subscription", { - p_brand_id: brandId, - p_subscription_id: "", - p_status: "canceled", - p_current_period_end: null, - }); + await rpc("set_brand_subscription", [ + brandId, + "", + "canceled", + null, + ]); // Disable all add-on features 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 ───────────────────────────────────────────────────── export async function sendPastDueNotification(brandId: string): Promise { - const brandData = await rpc>("get_brand_subscription", { p_brand_id: brandId }); - const brand = brandData[0] as unknown as { name?: string } | undefined; + const brandRows = await rpc>("get_brand_subscription", [brandId]); + const brand = brandRows[0]; const brandName = brand?.name ?? "your brand"; const adminEmail = await getAdminEmail(brandId); - await rpc("enqueue_notification", { - p_brand_id: brandId, - p_email_to: adminEmail ?? "team@cielohermosa.com", - p_subject: `Payment Failed — ${brandName}`, - p_body_html: ` + await rpc("enqueue_notification", [ + brandId, + adminEmail ?? "team@cielohermosa.com", + `Payment Failed — ${brandName}`, + `

Payment Failed

We were unable to charge your card for the Cielo Hermosa platform subscription.

Please update your payment method within 7 days to avoid service interruption.

Update Payment Method →

`, - 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 ─────────────────────────────────────────────────────────────────── @@ -267,9 +260,10 @@ async function getAdminEmail(brandId: string): Promise { try { const data = await rpc<{ notification_email?: string; from_email?: string }>( "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 { return null; } @@ -296,4 +290,4 @@ export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "a if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] }; return null; -} \ No newline at end of file +} diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index 46a0196..0db0658 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -10,7 +10,9 @@ * 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 = | "harvest_reach" @@ -170,25 +172,23 @@ export async function isFeatureEnabled( async function fetchBrandFeatures( brandId: string ): Promise | null> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!supabaseUrl || !supabaseKey) return null; - try { - 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 }), - } + const rows = await withTenant(brandId, (db) => + db + .select({ featureFlags: brandSettings.featureFlags }) + .from(brandSettings) + .where(eq(brandSettings.tenantId, brandId)) + .limit(1) ); - if (!res.ok) return null; - const data = await res.json(); - return data ?? null; + const flags = rows[0]?.featureFlags as Record | null | undefined; + if (!flags || typeof flags !== "object") return null; + // Coerce to boolean (JSONB may store as boolean, string, or number) + const result = {} as Record; + for (const key of Object.keys(ADDON_CATALOG) as BrandFeatureKey[]) { + const v = (flags as Record)[key]; + result[key] = v === true || v === "true" || v === 1 || v === "1"; + } + return result; } catch { return null; } diff --git a/src/lib/stripe-billing.ts b/src/lib/stripe-billing.ts index c17129c..92d510f 100644 --- a/src/lib/stripe-billing.ts +++ b/src/lib/stripe-billing.ts @@ -2,6 +2,7 @@ // Supports plans, add-ons, upgrades, cancellations, and customer portal import Stripe from "stripe"; +import { pool } from "@/lib/db"; // Lazy-initialize Stripe client to avoid build-time errors when env vars aren't set let _stripe: Stripe | null = null; @@ -565,27 +566,17 @@ async function updateBrandSubscription( periodEnd: number | null ) { if (!brandId) return; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - + try { - await fetch( - `${supabaseUrl}/rest/v1/rpc/set_brand_subscription`, - { - method: "POST", - headers: { - apikey: serviceKey, - "Content-Type": "application/json", - }, - 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, - }), - } + await pool.query( + "SELECT set_brand_subscription($1, $2, $3, $4, $5)", + [ + brandId, + subscriptionId, + status, + planTier ?? null, + periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + ] ); } catch (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) { if (!brandId || !addonKey) return; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - + try { - await fetch( - `${supabaseUrl}/rest/v1/rpc/set_brand_feature`, - { - method: "POST", - headers: { - apikey: serviceKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: brandId, - p_feature_key: addonKey, - p_enabled: true, - }), - } + await pool.query( + "SELECT set_brand_feature($1, $2, $3)", + [brandId, addonKey, true] ); } catch (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) { if (!brandId || !addonKey) return; - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; - + try { - await fetch( - `${supabaseUrl}/rest/v1/rpc/set_brand_feature`, - { - method: "POST", - headers: { - apikey: serviceKey, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - p_brand_id: brandId, - p_feature_key: addonKey, - p_enabled: false, - }), - } + await pool.query( + "SELECT set_brand_feature($1, $2, $3)", + [brandId, addonKey, false] ); } catch (error) { console.error("Failed to disable addon feature:", error);