migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)
This commit is contained in:
@@ -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<string, unknown> | 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<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(flags ?? {})) {
|
||||
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
// 4) Active product count — same semantics as the dashboard
|
||||
// (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<string, boolean>,
|
||||
enabledAddons,
|
||||
addons,
|
||||
displayedInvoiceAmount,
|
||||
monthlyTotal,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Record<string, boolean>> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
// get_brand_features returns JSONB — a single object, not an array
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json();
|
||||
if (typeof data !== "object" || data === null) return {};
|
||||
return data as Record<string, boolean>;
|
||||
const res = await pool.query<{ feature_flags: Record<string, unknown> | 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<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(flags)) {
|
||||
out[k] = v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
|
||||
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);
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AIProviderSettings> {
|
||||
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<CustomIntegration[]> {
|
||||
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<CustomIntegration>(
|
||||
"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<CustomIntegration>(
|
||||
"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 };
|
||||
}
|
||||
@@ -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<ResendCredentials> {
|
||||
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<TwilioCredentials> {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SyncResu
|
||||
|
||||
const errors: string[] = [];
|
||||
const synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
// Fetch Square inventory counts for the location
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
@@ -85,8 +85,6 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
|
||||
|
||||
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<SyncResult>
|
||||
// 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)}`);
|
||||
|
||||
@@ -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<SyncResult>
|
||||
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<SyncResult>
|
||||
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<SyncResul
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
@@ -263,15 +249,13 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
|
||||
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
|
||||
const imageUrl = obj.item.image_url ?? null;
|
||||
|
||||
// Sync to RC via bulk_upsert_products RPC
|
||||
const upsertRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_products: [
|
||||
// Sync to RC via SECURITY DEFINER RPC bulk_upsert_products
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT bulk_upsert_products($1, $2::jsonb)",
|
||||
[
|
||||
brandId,
|
||||
JSON.stringify([
|
||||
{
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
@@ -280,15 +264,12 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
|
||||
active: true,
|
||||
image_url: imageUrl,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertRes.ok) {
|
||||
]),
|
||||
]
|
||||
);
|
||||
synced++;
|
||||
} else {
|
||||
const errText = await upsertRes.text();
|
||||
} catch (e: unknown) {
|
||||
const errText = e instanceof Error ? e.message : String(e);
|
||||
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type SyncLogEntry = {
|
||||
id: string;
|
||||
@@ -70,20 +70,10 @@ export async function getSyncLog(brandId: string): Promise<{
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
const { rows: logs } = await pool.query<SyncLogEntry>(
|
||||
"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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Wholesal
|
||||
return { success: false, error: "No session returned from auth" };
|
||||
}
|
||||
|
||||
// Find the wholesale customer record for this user
|
||||
const customerRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseAnonKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: "placeholder", // will use any-brand lookup below
|
||||
p_user_id: data.user.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Find the wholesale customer record for this user (SECURITY DEFINER RPC).
|
||||
// The result is intentionally not consumed here — the portal page resolves
|
||||
// the actual customer on load using the cookie's access_token.
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
|
||||
["00000000-0000-0000-0000-000000000000", data.user.id]
|
||||
);
|
||||
} catch {
|
||||
// Customer may not be linked yet; portal will resolve.
|
||||
}
|
||||
|
||||
// If no brand_id known, try all brands — just use first active one found
|
||||
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
|
||||
|
||||
+177
-246
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function registerWholesaleCustomer(params: {
|
||||
brandId: string;
|
||||
@@ -11,43 +11,33 @@ export async function registerWholesaleCustomer(params: {
|
||||
email: string;
|
||||
phone?: string;
|
||||
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
|
||||
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<WholesalePricingOverride[]> {
|
||||
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<WholesaleCustomerOrder[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
+335
-520
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user