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
}
+20 -29
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 };
}
+75 -84
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 }),
}
);
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
return { success: true };
try {
await pool.query(
"SELECT update_brand_plan_tier($1, $2)",
[brandId, planTier]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update plan tier" };
}
}
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }),
}
);
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
return { success: true };
try {
await pool.query(
"SELECT update_brand_stripe_customer_id($1, $2)",
[brandId, stripeCustomerId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update Stripe customer ID" };
}
}
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
// Replicate get_brand_plan_info via a JOIN on tenants + plans
const res = await pool.query<{
plan_tier: string;
plan_name: string | null;
max_users: number;
max_stops_monthly: number;
max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
}>(
`SELECT
t.plan_tier,
t.name AS plan_name,
COALESCE(t.max_users, 1) AS max_users,
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
WHERE t.id = $1`,
[brandId]
);
if (!res.ok) return { success: false, error: "Failed to fetch plan info" };
const data = await res.json();
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
const data = res.rows[0];
if (!data) return { success: false, error: "Brand not found" };
return { success: true, data };
}
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
// get_brand_features returns JSONB — a single object, not an array
if (!res.ok) return {};
const data = await res.json();
if (typeof data !== "object" || data === null) return {};
return data as Record<string, boolean>;
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
[brandId]
);
const flags = res.rows[0]?.feature_flags ?? {};
if (!flags || typeof flags !== "object") return {};
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
out[k] = v === true || v === "true" || v === 1 || v === "1";
}
return out;
}
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) return [];
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
}
try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
const data = res.rows;
if (!Array.isArray(data)) return [];
return data.slice(0, limit);
} catch {
return [];
}
}
+60 -102
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]
);
const data = res.rows[0];
if (!data) {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
}
);
if (!response.ok) {
return {
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.api_key ?? "",
orgId: data.org_id ?? undefined,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint ?? undefined,
};
} catch {
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
}
const data = await response.json();
return {
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.api_key ?? "",
orgId: data.org_id,
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.custom_endpoint,
};
}
// ── Save AI provider settings ───────────────────────────────────────────────────
@@ -75,9 +73,6 @@ export async function setAIProviderSettings(
const current = await getAIProviderSettings(brandId);
const merged = { ...current, ...settings };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const payload = {
provider: merged.provider,
api_key: merged.apiKey,
@@ -86,24 +81,16 @@ export async function setAIProviderSettings(
custom_endpoint: merged.customEndpoint ?? null,
};
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }),
}
);
const data = await response.json();
if (!response.ok || !data) {
return { success: false, error: data?.message ?? "Failed to save" };
try {
await pool.query(
"SELECT set_ai_provider_settings($1, $2::jsonb)",
[brandId, JSON.stringify(payload)]
);
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: msg };
}
return { success: true };
}
// ── Dynamic AI client factory ────────────────────────────────────────────────────
@@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
const data = await response.json();
return data ?? [];
try {
const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
[brandId]
);
return res.rows ?? [];
} catch {
return [];
}
}
export async function upsertCustomIntegration(
@@ -234,25 +212,16 @@ export async function upsertCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
}
);
const data = await response.json();
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
return { success: true, integrations: data };
try {
const res = await pool.query<CustomIntegration>(
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
[brandId, JSON.stringify(integration)]
);
return { success: true, integrations: res.rows };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
return { success: false, error: msg };
}
}
export async function deleteCustomIntegration(
@@ -263,25 +232,14 @@ export async function deleteCustomIntegration(
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey!),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
}
);
if (!response.ok) {
const data = await response.json();
return { success: false, error: data?.message ?? "Failed to delete" };
try {
await pool.query(
"SELECT delete_custom_integration($1, $2)",
[brandId, integrationId]
);
return { success: true };
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to delete";
return { success: false, error: msg };
}
return { success: true };
}
+50 -78
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 }),
}
);
if (!response.ok) {
try {
const res = await pool.query<{
api_key: string | null;
from_email: string | null;
from_name: string | null;
}>(
"SELECT * FROM get_resend_credentials($1)",
[brandId]
);
const data = res.rows[0];
if (!data) return { api_key: null, from_email: null, from_name: null };
return {
api_key: data.api_key ?? null,
from_email: data.from_email ?? null,
from_name: data.from_name ?? null,
};
} catch {
return { api_key: null, from_email: null, from_name: null };
}
const data = await response.json();
return {
api_key: data?.api_key ?? null,
from_email: data?.from_email ?? null,
from_name: data?.from_name ?? null,
};
}
export async function saveResendCredentials(
@@ -66,9 +63,6 @@ export async function saveResendCredentials(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge
const current = await getResendCredentials(brandId);
const merged = {
@@ -77,50 +71,39 @@ export async function saveResendCredentials(
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
};
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_credentials: merged,
}),
}
);
if (!response.ok) {
try {
await pool.query(
"SELECT set_resend_credentials($1, $2::jsonb)",
[brandId, JSON.stringify(merged)]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save Resend credentials" };
}
return { success: true };
}
// ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) {
try {
const res = await pool.query<{
account_sid: string | null;
auth_token: string | null;
phone_number: string | null;
}>(
"SELECT * FROM get_twilio_credentials($1)",
[brandId]
);
const data = res.rows[0];
if (!data) return { account_sid: null, auth_token: null, phone_number: null };
return {
account_sid: data.account_sid ?? null,
auth_token: data.auth_token ?? null,
phone_number: data.phone_number ?? null,
};
} catch {
return { account_sid: null, auth_token: null, phone_number: null };
}
const data = await response.json();
return {
account_sid: data?.account_sid ?? null,
auth_token: data?.auth_token ?? null,
phone_number: data?.phone_number ?? null,
};
}
export async function saveTwilioCredentials(
@@ -141,9 +124,6 @@ export async function saveTwilioCredentials(
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Get current credentials to merge
const current = await getTwilioCredentials(brandId);
const merged = {
@@ -152,23 +132,15 @@ export async function saveTwilioCredentials(
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
};
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_credentials: merged,
}),
}
);
if (!response.ok) {
try {
await pool.query(
"SELECT set_twilio_credentials($1, $2::jsonb)",
[brandId, JSON.stringify(merged)]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save Twilio credentials" };
}
return { success: true };
}
// ── Test Connection Functions ─────────────────────────────────────────────────
@@ -229,4 +201,4 @@ export async function testTwilioConnection(
} catch (err) {
return { ok: false, message: "Network error - please check your connection" };
}
}
}
+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
+39 -29
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 }) => ({
id: null, // product lookup not available in this flow
quantity: li.quantity,
fulfillment: "shipping",
})),
p_subtotal: total,
p_payment_processor: "square",
p_payment_status: "paid",
p_payment_transaction_id: payment.id,
}),
// Call SECURITY DEFINER RPC create_order_with_items
let createOk = false;
let errText = "";
try {
const rpcRes = await pool.query(
`SELECT * FROM create_order_with_items(
$1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10
)`,
[
idempotencyKey,
customerName,
customerEmail,
"",
null, // Square orders don't have RC stop_id
JSON.stringify(
lineItems.map((li: { name: string; quantity: number; price: number }) => ({
id: null, // product lookup not available in this flow
quantity: li.quantity,
fulfillment: "shipping",
}))
),
total,
"square",
"paid",
payment.id,
]
);
createOk = rpcRes.rows.length > 0;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
// 409 / unique violation = already exists (idempotent)
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
createOk = true;
} else {
errText = msg.slice(0, 100);
}
);
}
if (createRes.ok || createRes.status === 409) {
// 409 = already exists (idempotent)
if (createOk) {
synced++;
} else {
const errText = await createRes.text();
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
errors.push(`Payment ${payment.id}: ${errText}`);
}
} catch (err) {
errors.push(`Payment ${payment.id}: ${String(err)}`);
+21 -40
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 };
}
+23 -53
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,
}),
}
);
if (!res.ok) {
const error = await res.text();
try {
// Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
"SELECT set_stripe_connect_account($1, $2)",
[brandId, accountId]
);
return { success: true };
} catch (e: unknown) {
const error = e instanceof Error ? e.message : String(e);
return { success: false, error: `Failed to save: ${error}` };
}
return { success: true };
}
/**
@@ -220,23 +197,16 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
return { success: false, error: "Not authorized" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
try {
// SECURITY DEFINER RPC disconnects the Stripe Connect account
await pool.query(
"SELECT disconnect_stripe_connect($1)",
[brandId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to disconnect Stripe account" };
}
return { success: true };
}
/**
+12 -16
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
+177 -246
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,
]
);
const result = Array.isArray(rows) ? rows[0] : rows;
if (!result?.success) {
return { success: false, error: result?.error ?? "Registration failed." };
}
);
if (!response.ok) {
const err = await response.json();
// Supabase error format: { "message": "...", "code": "...", ... }
// Our RPC error format: { "success": false, "error": "..." }
return { success: false, error: err.message ?? err.error ?? "Registration failed." };
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
} catch (e: unknown) {
const err = e instanceof Error ? e.message : String(e);
return { success: false, error: err || "Registration failed." };
}
const data = await response.json();
// Normalize: RPC may return an array (single row) or a plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Registration failed." };
}
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
}
export async function getPendingWholesaleRegistrations(brandId: string) {
@@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_pending_wholesale_registrations($1)",
[brandId]
);
return rows;
} catch {
return [];
}
}
export async function approveWholesaleRegistration(
@@ -88,33 +70,19 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_registration_id: registrationId,
p_brand_id: brandId,
p_action: action,
}),
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM approve_wholesale_registration($1, $2, $3)",
[registrationId, brandId, action]
);
const result = Array.isArray(rows) ? rows[0] : rows;
if (!result?.success) {
return { success: false, error: result?.error ?? "Failed to process registration." };
}
);
if (!response.ok) return { success: false, error: "Failed to process registration." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Failed to process registration." };
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." };
}
return { success: true };
}
export async function getWholesaleCustomerByUser(
@@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data ?? null;
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
[brandId, userId]
);
return (rows[0] as {
id: string;
user_id: string;
company_name: string;
contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
}
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
@@ -165,40 +134,41 @@ export async function getWholesaleCustomer(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`,
{
headers: svcHeaders(supabaseKey),
}
);
if (!response.ok) return null;
const data = await response.json();
return data?.[0] ?? null;
try {
const { rows } = await pool.query(
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
FROM wholesale_customers
WHERE id = $1
LIMIT 1`,
[customerId]
);
return (rows[0] as {
id: string;
user_id: string;
company_name: string;
contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
}
export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
return rows;
} catch {
return [];
}
}
export async function submitWholesaleOrder(params: {
@@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number;
idempotent?: boolean;
}> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: {
unit_price: i.unit_price,
}));
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: params.brandId,
p_customer_id: params.customerId,
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null,
p_items: itemsJson,
p_notes: params.notes ?? null,
p_checkout_session_id: checkoutSessionId,
}),
}
);
let result: {
success?: boolean;
error?: string;
order_id?: string;
invoice_number?: string | null;
status?: string;
deposit_required?: number;
credit_limit?: number;
outstanding_balance?: number;
order_total?: number;
idempotent?: boolean;
} | null = null;
if (!response.ok) return { success: false, error: "Failed to create order." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
try {
const { rows } = await pool.query(
`SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`,
[
params.brandId,
params.customerId,
params.anticipatedPickupDate ?? null,
JSON.stringify(itemsJson),
params.notes ?? null,
checkoutSessionId,
]
);
result = Array.isArray(rows) ? rows[0] : rows;
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to create order." };
}
if (!result.success) {
if (!result?.success) {
return {
success: false,
error: result.error ?? "Failed to create order.",
creditLimit: result.credit_limit,
outstandingBalance: result.outstanding_balance,
orderTotal: result.order_total,
error: result?.error ?? "Failed to create order.",
creditLimit: result?.credit_limit,
outstandingBalance: result?.outstanding_balance,
orderTotal: result?.order_total,
};
}
// Fire webhook — fire-and-forget, don't block the response
enqueueWholesaleWebhookForOrderCreated(
result.order_id,
result.order_id!,
params.brandId,
params.customerId,
result.invoice_number,
result.invoice_number ?? null,
Number(result.deposit_required) || 0
).catch(() => {});
return {
success: true,
orderId: result.order_id,
invoiceNumber: result.invoice_number,
invoiceNumber: result.invoice_number ?? undefined,
status: result.status,
depositRequired: result.deposit_required,
idempotent: result.idempotent ?? false,
@@ -287,18 +260,15 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_event_type: "order_created",
p_order_id: orderId,
p_brand_id: brandId,
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
}),
});
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"order_created",
orderId,
brandId,
JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }),
]
);
} catch (_) {}
}
@@ -344,23 +314,15 @@ export type WholesalePricingOverride = {
};
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
[customerId]
);
return rows as WholesalePricingOverride[];
} catch {
return [];
}
}
export async function upsertWholesaleCustomerPricing(params: {
@@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string;
customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
p_custom_unit_price: params.customUnitPrice,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
return { success: true };
try {
await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
[params.customerId, params.productId, params.customUnitPrice]
);
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" };
}
}
export async function deleteWholesaleCustomerPricing(params: {
customerId: string;
productId: string;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
return { success: true };
try {
await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
[params.customerId, params.productId]
);
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" };
}
}
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
[customerId]
);
return rows as WholesaleCustomerOrder[];
} catch {
return [];
}
}
+335 -520
View File
File diff suppressed because it is too large Load Diff
+15 -23
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { 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,
+17 -37
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 }),
}
);
if (!claimRes.ok) {
const errText = await claimRes.text();
// Claim a pending sync entry from the queue via SECURITY DEFINER RPC
let entries: Array<{ id: string; brand_id: string }> = [];
try {
const { rows } = await pool.query<{ id: string; brand_id: string }>(
"SELECT * FROM claim_square_sync_queue($1)",
[brandId]
);
entries = rows;
} catch (e: unknown) {
const errText = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 });
}
const entries = await claimRes.json();
if (!entries || entries.length === 0 || entries[0] === null) {
return NextResponse.json({ processed: 0, message: "No pending entries" });
}
@@ -46,29 +41,14 @@ export async function GET(request: Request) {
const newStatus = result.success ? "done" : "failed";
const lastError = result.errors.length > 0 ? result.errors[0] : null;
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: entry.brand_id,
p_error: lastError,
}),
}
await pool.query(
"SELECT update_square_sync_timestamp($1, $2)",
[entry.brand_id, lastError]
);
await fetch(
`${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${entry.id}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
status: newStatus,
processed_at: new Date().toISOString(),
last_error: lastError,
}),
}
await pool.query(
"UPDATE square_sync_queue SET status = $1, processed_at = $2, last_error = $3 WHERE id = $4",
[newStatus, new Date().toISOString(), lastError, entry.id]
);
return NextResponse.json({
@@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { getAdminUser } from "@/lib/admin-permissions";
import { formatDate } from "@/lib/format-date";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
type InvoiceSettings = {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
};
export async function GET(
req: NextRequest,
@@ -11,78 +34,40 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Token-based customer download ────────────────────────────────────────────
if (token) {
// Look up order by ID + token. Falls back to a direct select so the
// invoice_token is checked server-side (not exposed to the client).
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`,
{
headers: svcHeaders(supabaseKey),
}
// Look up order by ID + token. The invoice_token is checked server-side.
const tokenRes = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
[orderId, token]
);
if (!orderRes.ok) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
// Proceed to generate PDF — order token is verified
const brandId = orders[0].brand_id;
const brandId = tokenRes.rows[0].brand_id;
// Fetch full order data for PDF
const fullRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const fullRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
if (!fullRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
const allOrders = await fullRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const allOrders = fullRes.rows;
const order = allOrders.find(o => o.id === orderId);
if (!order) {
return new NextResponse("Not found", { status: 404 });
}
// Fetch brand-specific settings for invoice header
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const settingsRes = await pool.query<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,6 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
export async function GET(
req: NextRequest,
@@ -9,24 +25,17 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
if (!process.env.DATABASE_URL) {
return new NextResponse("Server misconfiguration", { status: 500 });
}
// ── Token-gated download (customer portal) ──────────────────────────────────
if (token) {
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`,
{ headers: svcHeaders(supabaseKey) }
const tokenRes = await pool.query<{ id: string }>(
"SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
[orderId, token]
);
if (!orderRes.ok || orderRes.status === 204) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
}
@@ -36,64 +45,38 @@ export async function GET(
let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id
const directRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`,
{ headers: svcHeaders(supabaseKey) }
const directRes = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1",
[orderId]
);
if (directRes.ok) {
const direct = await directRes.json();
if (direct && direct.length > 0) {
brandId = direct[0].brand_id;
}
if (directRes.rows.length > 0) {
brandId = directRes.rows[0].brand_id;
}
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const orderRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
if (!orderRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
const allOrders = await orderRes.json() as OrderRow[];
const allOrders = orderRes.rows;
const order = allOrders.find(o => o.id === orderId);
if (!order) {
return new NextResponse("Order not found", { status: 404 });
}
// Fetch settings for brand header
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
const settingsRes = await pool.query<{
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
);
const settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const settings = settingsRes.rows[0] ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
@@ -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.`,
}),
}
);
if (enqueueRes.ok) {
`Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
]
);
enqueued++;
} else {
} catch {
skipped++;
}
}
@@ -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() {
+45 -51
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;
}
@@ -296,4 +290,4 @@ export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "a
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
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;
}
+19 -56
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;
@@ -565,27 +566,17 @@ async function updateBrandSubscription(
periodEnd: number | null
) {
if (!brandId) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_subscription_status: status,
p_plan_tier: planTier,
p_current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
}),
}
await pool.query(
"SELECT set_brand_subscription($1, $2, $3, $4, $5)",
[
brandId,
subscriptionId,
status,
planTier ?? null,
periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
]
);
} catch (error) {
console.error("Failed to update brand subscription:", error);
@@ -594,25 +585,11 @@ async function updateBrandSubscription(
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: true,
}),
}
await pool.query(
"SELECT set_brand_feature($1, $2, $3)",
[brandId, addonKey, true]
);
} catch (error) {
console.error("Failed to enable addon feature:", error);
@@ -621,25 +598,11 @@ async function enableAddonFeature(brandId: string | undefined, addonKey: string
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: false,
}),
}
await pool.query(
"SELECT set_brand_feature($1, $2, $3)",
[brandId, addonKey, false]
);
} catch (error) {
console.error("Failed to disable addon feature:", error);