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

This commit is contained in:
2026-06-07 03:25:22 +00:00
parent eb9621d238
commit 99a3d66636
26 changed files with 1216 additions and 1776 deletions
+68 -40
View File
@@ -36,12 +36,12 @@
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { 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,
+5 -8
View File
@@ -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
}
+6 -9
View File
@@ -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
}
+19 -28
View File
@@ -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 };
}
+67 -76
View File
@@ -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 }),
}
try {
await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
[brandId, planTier]
);
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
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 }),
}
try {
await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
[brandId, stripeCustomerId]
);
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
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 }),
}
try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
if (!res.ok) return [];
const data = await res.json();
const data = res.rows;
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
} catch {
return [];
}
}
+51 -93
View File
@@ -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]
);
if (!response.ok) {
const data = res.rows[0];
if (!data) {
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,
orgId: data.org_id ?? undefined,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint,
customEndpoint: data.custom_endpoint ?? undefined,
};
} catch {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
}
}
// ── 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 }),
}
try {
await pool.query(
"SELECT set_ai_provider_settings($1, $2::jsonb)",
[brandId, JSON.stringify(payload)]
);
const data = await response.json();
if (!response.ok || !data) {
return { success: false, error: data?.message ?? "Failed to save" };
}
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: msg };
}
}
// ── 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 }),
}
try {
const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
[brandId]
);
if (!response.ok) return [];
const data = await response.json();
return data ?? [];
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 }),
}
try {
const res = await pool.query<CustomIntegration>(
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
[brandId, JSON.stringify(integration)]
);
const data = await response.json();
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
return { success: true, integrations: data };
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 }),
}
try {
await pool.query(
"SELECT delete_custom_integration($1, $2)",
[brandId, integrationId]
);
if (!response.ok) {
const data = await response.json();
return { success: false, error: data?.message ?? "Failed to delete" };
}
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete";
return { success: false, error: msg };
}
}
+45 -73
View File
@@ -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 }),
}
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]
);
if (!response.ok) {
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,
}),
}
try {
await pool.query(
"SELECT set_resend_credentials($1, $2::jsonb)",
[brandId, JSON.stringify(merged)]
);
if (!response.ok) {
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 }),
}
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]
);
if (!response.ok) {
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,
}),
}
try {
await pool.query(
"SELECT set_twilio_credentials($1, $2::jsonb)",
[brandId, JSON.stringify(merged)]
);
if (!response.ok) {
return { success: true };
} catch {
return { success: false, error: "Failed to save Twilio credentials" };
}
return { success: true };
}
// ── Test Connection Functions ─────────────────────────────────────────────────
+10 -14
View File
@@ -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
+36 -26
View File
@@ -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 }) => ({
// 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",
})),
p_subtotal: total,
p_payment_processor: "square",
p_payment_status: "paid",
p_payment_transaction_id: payment.id,
}),
}
}))
),
total,
"square",
"paid",
payment.id,
]
);
createOk = rpcRes.rows.length > 0;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
// 409 / unique violation = already exists (idempotent)
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
createOk = true;
} else {
errText = msg.slice(0, 100);
}
}
if (createRes.ok || createRes.status === 409) {
// 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)}`);
+20 -39
View File
@@ -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)}`);
}
}
+4 -14
View File
@@ -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 };
}
+21 -51
View File
@@ -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,
}),
}
try {
// Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
"SELECT set_stripe_connect_account($1, $2)",
[brandId, accountId]
);
if (!res.ok) {
const error = await res.text();
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 }),
}
try {
// SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
"SELECT disconnect_stripe_connect($1)",
[brandId]
);
if (!res.ok) {
return { success: true };
} catch {
return { success: false, error: "Failed to disconnect Stripe account" };
}
return { success: true };
}
/**
+11 -15
View File
@@ -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
+162 -231
View File
@@ -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,
]
);
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." };
}
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." };
const result = Array.isArray(rows) ? rows[0] : rows;
if (!result?.success) {
return { success: false, error: result?.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." };
}
}
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 }),
}
try {
const { rows } = await pool.query(
"SELECT * FROM get_pending_wholesale_registrations($1)",
[brandId]
);
if (!response.ok) return [];
return response.json();
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]
);
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." };
const result = Array.isArray(rows) ? rows[0] : rows;
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." };
}
}
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 }),
}
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
[brandId, userId]
);
if (!response.ok) return null;
const data = await response.json();
return data ?? null;
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),
}
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]
);
if (!response.ok) return null;
const data = await response.json();
return data?.[0] ?? null;
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 }),
}
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
if (!response.ok) return [];
return response.json();
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;
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 (!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;
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 }),
}
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
[customerId]
);
if (!response.ok) return [];
return response.json();
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,
}),
}
try {
await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
[params.customerId, params.productId, params.customUnitPrice]
);
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
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,
}),
}
try {
await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
[params.customerId, params.productId]
);
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
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 }),
}
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
[customerId]
);
if (!response.ok) return [];
return response.json();
return rows as WholesaleCustomerOrder[];
} catch {
return [];
}
}
+304 -489
View File
@@ -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<WholesaleOrd
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
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_orders`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: bid }),
}
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
if (!response.ok) return [];
return response.json();
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
@@ -214,23 +206,15 @@ export async function getWholesalePickupOrders(brandId?: string): Promise<Wholes
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
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_pickup_orders`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: bid }),
}
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
if (!response.ok) return [];
return response.json();
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
@@ -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 (!response.ok) return { success: false, error: "Failed to mark fulfilled" };
if (!rows || rows.length === 0) {
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 }),
}
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
if (!response.ok) return { success: false, error: "Failed to update order 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 }),
}
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
if (!response.ok) return { success: false, error: "Failed to delete order" };
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]
);
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" };
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
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]
);
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" };
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
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<Wholesale
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
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_customers`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: bid }),
}
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
if (!response.ok) return [];
return response.json();
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,
}),
}
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,
]
);
if (!response.ok) return { success: false, error: "Failed to save customer" };
const data = await response.json();
return { success: true, id: data.id };
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<WholesaleP
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
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: bid }),
}
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
if (!response.ok) return [];
return response.json();
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,
}),
}
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,
]
);
if (!response.ok) return { success: false, error: "Failed to save product" };
const data = await response.json();
return { success: true, id: data.id };
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<WholesaleS
return 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_settings`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: bid }),
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
if (!response.ok) return null;
const data = await response.json();
return data ?? null;
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,
}),
}
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,
]
);
if (!response.ok) return { success: false, error: "Failed to save settings" };
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]
);
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" };
data = Array.isArray(rows) ? rows[0] : rows;
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)]
);
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" };
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
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)]
);
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" };
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
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 }),
}
try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
if (!response.ok) return { pending: 0, sent: 0, failed: 0, total: 0 };
return response.json();
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 }),
}
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
if (!response.ok) return [];
return response.json();
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 }),
}
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: response.ok };
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,
}),
}
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,
]
);
if (!response.ok) return { success: false, error: "Failed to enqueue notification" };
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<WebhookSettings | 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_webhook_settings?brand_id=eq.${brandId}&select=*`,
{
headers: svcHeaders(supabaseKey),
}
try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
if (!response.ok) return null;
const data = await response.json();
return data?.[0] ?? null;
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<string, unknown> = {
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,
};
// 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 };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
@@ -1013,29 +835,16 @@ export async function enqueueWholesaleWebhook(
payload: Record<string, unknown> | 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,
}),
}
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]
);
if (!response.ok) return { success: false };
const data = await response.json();
return { success: true, logId: data };
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<Array<{
@@ -1047,15 +856,21 @@ export async function getRecentWebhookActivity(brandId: string, limit = 10): Pro
created_at: string;
response: string | null;
}>> {
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" },
}
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]
);
if (!res.ok) return [];
return res.json();
return rows;
} catch {
return [];
}
}
+15 -23
View File
@@ -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)
+15 -12
View File
@@ -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,
+16 -36
View File
@@ -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 }),
}
// 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]
);
if (!claimRes.ok) {
const errText = await claimRes.text();
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({
@@ -2,56 +2,9 @@ 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";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
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),
}
);
if (!orderRes.ok) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
// Proceed to generate PDF — order token is verified
const brandId = orders[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 }),
}
);
if (!fullRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
const allOrders = await fullRes.json() as Array<{
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
@@ -64,25 +17,57 @@ export async function GET(
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,
{ params }: { params: Promise<{ orderId: string }> }
) {
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
// ── Token-based customer download ────────────────────────────────────────────
if (token) {
// 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 (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
// Proceed to generate PDF — order token is verified
const brandId = tokenRes.rows[0].brand_id;
// Fetch full order data for PDF
const fullRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
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<InvoiceSettings>(
"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<OrderRow>(
"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<InvoiceSettings>(
"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);
@@ -1,66 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
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) {
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) }
);
if (!orderRes.ok || orderRes.status === 204) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
}
// ── Fetch full order + settings ────────────────────────────────────────────
// Use the RPC for brand-scoped fetch (works for both token + admin paths)
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) }
);
if (directRes.ok) {
const direct = await directRes.json();
if (direct && direct.length > 0) {
brandId = direct[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 }),
}
);
if (!orderRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
type OrderRow = {
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
@@ -74,26 +16,67 @@ export async function GET(
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[];
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ orderId: string }> }
) {
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
if (!process.env.DATABASE_URL) {
return new NextResponse("Server misconfiguration", { status: 500 });
}
// ── Token-gated download (customer portal) ──────────────────────────────────
if (token) {
const tokenRes = await pool.query<{ id: string }>(
"SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
[orderId, token]
);
if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
}
// ── Fetch full order + settings ────────────────────────────────────────────
// Use the RPC for brand-scoped fetch (works for both token + admin paths)
let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id
const directRes = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1",
[orderId]
);
if (directRes.rows.length > 0) {
brandId = directRes.rows[0].brand_id;
}
const orderRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
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);
@@ -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)}`,
`
<h2>Order Overdue for Pickup</h2>
<p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p>
${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""}
<p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p>
`,
p_body_text: `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
}),
}
`Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
]
);
if (enqueueRes.ok) {
enqueued++;
} else {
} catch {
skipped++;
}
}
@@ -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<string, {
@@ -62,13 +51,17 @@ export async function POST() {
}> = {};
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" },
});
@@ -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() {
+44 -50
View File
@@ -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<T = unknown>(
async function rpc<T = Record<string, unknown>>(
fn: string,
body: Record<string, unknown>
params: ReadonlyArray<unknown>
): Promise<T> {
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<T>;
const placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
const { rows } = await pool.query<Record<string, unknown>>(
`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<Array<{ stripe_customer_id: string | null }>>(
const brandRows = await rpc<Array<BrandSubscription>>(
"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<BrandSubscription[]>("get_brand_subscription", { p_brand_id: brandId });
const brand = brandData[0] as unknown as BrandSubscription | undefined;
const brandRows = await rpc<BrandSubscription[]>("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<void> {
const brandData = await rpc<Array<{ name: string }>>("get_brand_subscription", { p_brand_id: brandId });
const brand = brandData[0] as unknown as { name?: string } | undefined;
const brandRows = await rpc<Array<{ name: string }>>("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}`,
`
<h2>Payment Failed</h2>
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
<p>Please update your payment method within 7 days to avoid service interruption.</p>
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
`,
p_body_text: `Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
});
`Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
]);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -267,9 +260,10 @@ async function getAdminEmail(brandId: string): Promise<string | null> {
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;
}
+18 -18
View File
@@ -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<Record<BrandFeatureKey, boolean> | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) return null;
try {
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<string, unknown> | null | undefined;
if (!flags || typeof flags !== "object") return null;
// Coerce to boolean (JSONB may store as boolean, string, or number)
const result = {} as Record<BrandFeatureKey, boolean>;
for (const key of Object.keys(ADDON_CATALOG) as BrandFeatureKey[]) {
const v = (flags as Record<string, unknown>)[key];
result[key] = v === true || v === "true" || v === 1 || v === "1";
}
return result;
} catch {
return null;
}
+16 -53
View File
@@ -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;
@@ -566,26 +567,16 @@ async function updateBrandSubscription(
) {
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);
@@ -595,24 +586,10 @@ 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);
@@ -622,24 +599,10 @@ 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);