migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)
This commit is contained in:
@@ -36,12 +36,12 @@
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
import { and, eq, count } from "drizzle-orm";
|
||||
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
export type BillingSubscriptionStatus =
|
||||
| "active"
|
||||
| "trialing"
|
||||
@@ -97,48 +97,76 @@ export async function getBillingOverview(
|
||||
if (!brandId) return { success: false, error: "brandId required" };
|
||||
|
||||
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
|
||||
const planRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
// Replicate the RPC inline via raw SQL on tenants + plans.
|
||||
const planRes = await pool.query<{
|
||||
plan_tier: string;
|
||||
plan_name: string | null;
|
||||
max_users: number;
|
||||
max_stops_monthly: number;
|
||||
max_products: number;
|
||||
usage: { users: number; stops_this_month: number; products: number } | null;
|
||||
}>(
|
||||
`SELECT
|
||||
t.plan_tier,
|
||||
t.name AS plan_name,
|
||||
COALESCE(t.max_users, 1) AS max_users,
|
||||
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
|
||||
COALESCE(t.max_products, 25) AS max_products,
|
||||
jsonb_build_object(
|
||||
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
|
||||
'stops_this_month', (SELECT count(*)::int FROM stops s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.created_at >= date_trunc('month', now())),
|
||||
'products', (SELECT count(*)::int FROM products p
|
||||
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
) AS usage
|
||||
FROM tenants t
|
||||
WHERE t.id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
const planData = planRes.ok ? await planRes.json() : null;
|
||||
const planData = planRes.rows[0] ?? null;
|
||||
|
||||
// 2) Brand row: subscription state, name, stripe_customer_id
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
|
||||
{ headers: svcHeaders(supabaseKey) }
|
||||
const brandRes = await pool.query<{
|
||||
name: string;
|
||||
stripe_customer_id: string | null;
|
||||
stripe_subscription_id: string | null;
|
||||
stripe_subscription_status: string | null;
|
||||
stripe_current_period_end: string | null;
|
||||
}>(
|
||||
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
|
||||
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
|
||||
FROM tenants WHERE id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
const brandRows = brandRes.ok ? await brandRes.json() : [];
|
||||
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
|
||||
const brand = brandRes.rows[0] ?? null;
|
||||
|
||||
// 3) Enabled add-ons (feature flags)
|
||||
const addonsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
// 3) Enabled add-ons (feature flags from brand_settings)
|
||||
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||
`SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
|
||||
const flags = addonsRes.rows[0]?.feature_flags ?? {};
|
||||
const enabledAddons: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(flags ?? {})) {
|
||||
enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
|
||||
// 4) Active product count — same semantics as the dashboard
|
||||
// (active=true AND deleted_at IS NULL). Keeps the billing page in
|
||||
// sync with /admin "Active Products" stat.
|
||||
const productRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
|
||||
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
|
||||
// (active=true). Keeps the billing page in sync with /admin
|
||||
// "Active Products" stat.
|
||||
const productCountRows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(products)
|
||||
.where(
|
||||
and(
|
||||
eq(products.tenantId, brandId),
|
||||
eq(products.active, true)
|
||||
)
|
||||
)
|
||||
);
|
||||
const productCountHeader = productRes.headers.get("Content-Range");
|
||||
let activeProductCount = 0;
|
||||
if (productCountHeader && productCountHeader.includes("/")) {
|
||||
const total = productCountHeader.split("/").pop();
|
||||
activeProductCount = parseInt(total ?? "0", 10) || 0;
|
||||
}
|
||||
const activeProductCount = Number(productCountRows[0]?.value ?? 0);
|
||||
|
||||
// 5) Admin context (so we know if the user can manage / is platform)
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -146,7 +174,7 @@ export async function getBillingOverview(
|
||||
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
|
||||
|
||||
// ── Normalize plan data ──────────────────────────────────────────────────
|
||||
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
|
||||
const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter");
|
||||
const limits = {
|
||||
max_users: planData?.max_users ?? 1,
|
||||
max_stops_monthly: planData?.max_stops_monthly ?? 10,
|
||||
@@ -209,7 +237,7 @@ export async function getBillingOverview(
|
||||
success: true,
|
||||
data: {
|
||||
brandId,
|
||||
brandName: brand?.name ?? planData?.brand_name ?? null,
|
||||
brandName: brand?.name ?? planData?.plan_name ?? null,
|
||||
planTier,
|
||||
planCycle,
|
||||
planMonthlyPrice,
|
||||
@@ -220,7 +248,7 @@ export async function getBillingOverview(
|
||||
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
|
||||
limits,
|
||||
usage,
|
||||
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
|
||||
enabledAddons,
|
||||
addons,
|
||||
displayedInvoiceAmount,
|
||||
monthlyTotal,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
type LineItem = {
|
||||
id: string;
|
||||
@@ -32,16 +32,13 @@ export async function createRetailStripeCheckoutSession(
|
||||
}));
|
||||
|
||||
// Get brand name for Stripe metadata
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
let brandName = "Route Commerce";
|
||||
try {
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
const brandRes = await pool.query<{ name: string }>(
|
||||
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const brands = await brandRes.json() as Array<{ name: string }>;
|
||||
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||
} catch {
|
||||
// use default
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Creates a Stripe PaymentIntent for the supplied cart so the browser
|
||||
@@ -63,17 +63,14 @@ export async function createRetailPaymentIntent(
|
||||
}
|
||||
|
||||
// Pull the brand name for Stripe receipts + metadata
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
let brandName = "Route Commerce";
|
||||
if (supabaseUrl && supabaseKey && brandId) {
|
||||
if (brandId) {
|
||||
try {
|
||||
const brandRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
const brandRes = await pool.query<{ name: string }>(
|
||||
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const brands = (await brandRes.json()) as Array<{ name: string }>;
|
||||
if (brands?.[0]?.name) brandName = brands[0].name;
|
||||
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
|
||||
} catch {
|
||||
// ignore — use default
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Price ID config ────────────────────────────────────────────────────────────
|
||||
// Maps plan/addon keys to Stripe price IDs via environment variables
|
||||
@@ -43,16 +43,12 @@ export async function createStripeCheckoutSession(
|
||||
const priceId = getPriceId(priceKey);
|
||||
if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get brand's Stripe customer ID
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id,name`,
|
||||
{ headers: { ...svcHeaders(supabaseKey) } }
|
||||
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
|
||||
"SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const brands = await custRes.json() as Array<{ stripe_customer_id: string | null; name: string }>;
|
||||
const brand = brands[0];
|
||||
const brand = custRes.rows[0];
|
||||
if (!brand?.stripe_customer_id) {
|
||||
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
||||
}
|
||||
@@ -123,20 +119,15 @@ export async function cancelAddonSubscription(
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get active subscription for this brand
|
||||
const subRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
|
||||
"SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
if (!subRes.ok) return { success: false, error: "Failed to get subscription" };
|
||||
const subData = await subRes.json() as { stripe_subscription_id?: string };
|
||||
if (subRes.rows.length === 0) {
|
||||
return { success: false, error: "No active subscription found" };
|
||||
}
|
||||
const subData = subRes.rows[0];
|
||||
if (!subData?.stripe_subscription_id) {
|
||||
return { success: false, error: "No active subscription found" };
|
||||
}
|
||||
@@ -162,14 +153,14 @@ export async function cancelAddonSubscription(
|
||||
});
|
||||
|
||||
// Disable the feature flag locally
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: addonKey, p_enabled: false }),
|
||||
}
|
||||
);
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT set_brand_feature($1, $2, $3)",
|
||||
[brandId, addonKey, false]
|
||||
);
|
||||
} catch {
|
||||
// best-effort: webhook reconciliation will eventually fix the flag
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
@@ -13,16 +13,12 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) return { success: false, error: "Stripe not configured" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get stripe_customer_id from brands table
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=stripe_customer_id`,
|
||||
{ headers: svcHeaders(supabaseKey) }
|
||||
// Get stripe_customer_id from tenants table
|
||||
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
|
||||
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const custData = await custRes.json();
|
||||
const stripeCustomerId = custData?.[0]?.stripe_customer_id;
|
||||
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
|
||||
|
||||
if (!stripeCustomerId) {
|
||||
return { success: false, error: "No Stripe customer found. Complete Stripe setup in Payments settings first." };
|
||||
@@ -49,20 +45,15 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
|
||||
return { success: false, error: "Invalid plan tier" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to update plan tier" };
|
||||
return { success: true };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT update_brand_plan_tier($1, $2)",
|
||||
[brandId, planTier]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update plan tier" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -72,76 +63,76 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_stripe_customer_id: stripeCustomerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to update Stripe customer ID" };
|
||||
return { success: true };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT update_brand_stripe_customer_id($1, $2)",
|
||||
[brandId, stripeCustomerId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update Stripe customer ID" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
// Replicate get_brand_plan_info via a JOIN on tenants + plans
|
||||
const res = await pool.query<{
|
||||
plan_tier: string;
|
||||
plan_name: string | null;
|
||||
max_users: number;
|
||||
max_stops_monthly: number;
|
||||
max_products: number;
|
||||
usage: { users: number; stops_this_month: number; products: number } | null;
|
||||
}>(
|
||||
`SELECT
|
||||
t.plan_tier,
|
||||
t.name AS plan_name,
|
||||
COALESCE(t.max_users, 1) AS max_users,
|
||||
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
|
||||
COALESCE(t.max_products, 25) AS max_products,
|
||||
jsonb_build_object(
|
||||
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
|
||||
'stops_this_month', (SELECT count(*)::int FROM stops s
|
||||
WHERE s.tenant_id = t.id
|
||||
AND s.created_at >= date_trunc('month', now())),
|
||||
'products', (SELECT count(*)::int FROM products p
|
||||
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
|
||||
) AS usage
|
||||
FROM tenants t
|
||||
WHERE t.id = $1`,
|
||||
[brandId]
|
||||
);
|
||||
|
||||
if (!res.ok) return { success: false, error: "Failed to fetch plan info" };
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data) && typeof data !== "object") return { success: false, error: "Invalid plan info response" };
|
||||
const data = res.rows[0];
|
||||
if (!data) return { success: false, error: "Brand not found" };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
// get_brand_features returns JSONB — a single object, not an array
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json();
|
||||
if (typeof data !== "object" || data === null) return {};
|
||||
return data as Record<string, boolean>;
|
||||
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
|
||||
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
const flags = res.rows[0]?.feature_flags ?? {};
|
||||
if (!flags || typeof flags !== "object") return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [k, v] of Object.entries(flags)) {
|
||||
out[k] = v === true || v === "true" || v === 1 || v === "1";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.slice(0, limit);
|
||||
}
|
||||
try {
|
||||
const res = await pool.query(
|
||||
"SELECT * FROM get_wholesale_orders($1)",
|
||||
[brandId]
|
||||
);
|
||||
const data = res.rows;
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────────
|
||||
@@ -33,33 +33,31 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
|
||||
// ── Get AI provider settings ─────────────────────────────────────────────────────
|
||||
|
||||
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
try {
|
||||
const res = await pool.query<{
|
||||
provider: string | null;
|
||||
api_key: string | null;
|
||||
org_id: string | null;
|
||||
model: string | null;
|
||||
custom_endpoint: string | null;
|
||||
}>(
|
||||
"SELECT * FROM get_ai_provider_settings($1)",
|
||||
[brandId]
|
||||
);
|
||||
const data = res.rows[0];
|
||||
if (!data) {
|
||||
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.api_key ?? "",
|
||||
orgId: data.org_id ?? undefined,
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.custom_endpoint ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { provider: "openai", apiKey: "", model: "gpt-4o-mini" };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
provider: (data.provider as AIProvider) ?? "openai",
|
||||
apiKey: data.api_key ?? "",
|
||||
orgId: data.org_id,
|
||||
model: data.model ?? "gpt-4o-mini",
|
||||
customEndpoint: data.custom_endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Save AI provider settings ───────────────────────────────────────────────────
|
||||
@@ -75,9 +73,6 @@ export async function setAIProviderSettings(
|
||||
const current = await getAIProviderSettings(brandId);
|
||||
const merged = { ...current, ...settings };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const payload = {
|
||||
provider: merged.provider,
|
||||
api_key: merged.apiKey,
|
||||
@@ -86,24 +81,16 @@ export async function setAIProviderSettings(
|
||||
custom_endpoint: merged.customEndpoint ?? null,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_ai_provider_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_settings: payload }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data) {
|
||||
return { success: false, error: data?.message ?? "Failed to save" };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT set_ai_provider_settings($1, $2::jsonb)",
|
||||
[brandId, JSON.stringify(payload)]
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save";
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Dynamic AI client factory ────────────────────────────────────────────────────
|
||||
@@ -206,24 +193,15 @@ export async function getAIClient(brandId: string): Promise<{
|
||||
// ── Custom integrations ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_custom_integrations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data ?? [];
|
||||
try {
|
||||
const res = await pool.query<CustomIntegration>(
|
||||
"SELECT * FROM get_custom_integrations($1)",
|
||||
[brandId]
|
||||
);
|
||||
return res.rows ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertCustomIntegration(
|
||||
@@ -234,25 +212,16 @@ export async function upsertCustomIntegration(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration: integration }),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) return { success: false, error: data?.message ?? "Failed to save" };
|
||||
|
||||
return { success: true, integrations: data };
|
||||
try {
|
||||
const res = await pool.query<CustomIntegration>(
|
||||
"SELECT * FROM upsert_custom_integration($1, $2::jsonb)",
|
||||
[brandId, JSON.stringify(integration)]
|
||||
);
|
||||
return { success: true, integrations: res.rows };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to save";
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCustomIntegration(
|
||||
@@ -263,25 +232,14 @@ export async function deleteCustomIntegration(
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_custom_integration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey!),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_integration_id: integrationId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
return { success: false, error: data?.message ?? "Failed to delete" };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT delete_custom_integration($1, $2)",
|
||||
[brandId, integrationId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to delete";
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -24,28 +24,25 @@ export type SaveCredentialsResult =
|
||||
// ── Resend Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const res = await pool.query<{
|
||||
api_key: string | null;
|
||||
from_email: string | null;
|
||||
from_name: string | null;
|
||||
}>(
|
||||
"SELECT * FROM get_resend_credentials($1)",
|
||||
[brandId]
|
||||
);
|
||||
const data = res.rows[0];
|
||||
if (!data) return { api_key: null, from_email: null, from_name: null };
|
||||
return {
|
||||
api_key: data.api_key ?? null,
|
||||
from_email: data.from_email ?? null,
|
||||
from_name: data.from_name ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { api_key: null, from_email: null, from_name: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
api_key: data?.api_key ?? null,
|
||||
from_email: data?.from_email ?? null,
|
||||
from_name: data?.from_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveResendCredentials(
|
||||
@@ -66,9 +63,6 @@ export async function saveResendCredentials(
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getResendCredentials(brandId);
|
||||
const merged = {
|
||||
@@ -77,50 +71,39 @@ export async function saveResendCredentials(
|
||||
from_name: credentials.from_name !== undefined ? credentials.from_name : current.from_name,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_resend_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT set_resend_credentials($1, $2::jsonb)",
|
||||
[brandId, JSON.stringify(merged)]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save Resend credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Twilio Credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const res = await pool.query<{
|
||||
account_sid: string | null;
|
||||
auth_token: string | null;
|
||||
phone_number: string | null;
|
||||
}>(
|
||||
"SELECT * FROM get_twilio_credentials($1)",
|
||||
[brandId]
|
||||
);
|
||||
const data = res.rows[0];
|
||||
if (!data) return { account_sid: null, auth_token: null, phone_number: null };
|
||||
return {
|
||||
account_sid: data.account_sid ?? null,
|
||||
auth_token: data.auth_token ?? null,
|
||||
phone_number: data.phone_number ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { account_sid: null, auth_token: null, phone_number: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
account_sid: data?.account_sid ?? null,
|
||||
auth_token: data?.auth_token ?? null,
|
||||
phone_number: data?.phone_number ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveTwilioCredentials(
|
||||
@@ -141,9 +124,6 @@ export async function saveTwilioCredentials(
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get current credentials to merge
|
||||
const current = await getTwilioCredentials(brandId);
|
||||
const merged = {
|
||||
@@ -152,23 +132,15 @@ export async function saveTwilioCredentials(
|
||||
phone_number: credentials.phone_number !== undefined ? credentials.phone_number : current.phone_number,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_twilio_credentials`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_credentials: merged,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT set_twilio_credentials($1, $2::jsonb)",
|
||||
[brandId, JSON.stringify(merged)]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save Twilio credentials" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Test Connection Functions ─────────────────────────────────────────────────
|
||||
@@ -229,4 +201,4 @@ export async function testTwilioConnection(
|
||||
} catch (err) {
|
||||
return { ok: false, message: "Network error - please check your connection" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { withTenant } from "@/db/client";
|
||||
import { products } from "@/db/schema";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
@@ -110,30 +112,26 @@ export async function syncInventoryToSquare(
|
||||
const errors: string[] = [];
|
||||
const synced = 0;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Build product name → quantity map
|
||||
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
|
||||
|
||||
try {
|
||||
// Fetch product details from RC
|
||||
const productIds = items.map((i) => i.productId);
|
||||
const productsRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/products?id=in.(${productIds.join(",")})&select=id,name`,
|
||||
{
|
||||
headers: { ...svcHeaders(supabaseKey) },
|
||||
}
|
||||
const rows = await withTenant(brandId, (db) =>
|
||||
db
|
||||
.select({ id: products.id, name: products.name })
|
||||
.from(products)
|
||||
.where(inArray(products.id, productIds))
|
||||
);
|
||||
if (!productsRes.ok) {
|
||||
if (rows.length === 0 && productIds.length > 0) {
|
||||
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
|
||||
}
|
||||
const products: Array<{ id: string; name: string }> = await productsRes.json();
|
||||
|
||||
// Find Square catalog items matching product names and reduce quantity
|
||||
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
|
||||
|
||||
for (const product of products) {
|
||||
for (const product of rows) {
|
||||
const qty = itemQtyMap.get(product.id) ?? 0;
|
||||
if (qty <= 0) continue;
|
||||
|
||||
@@ -177,8 +175,6 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
|
||||
|
||||
const errors: string[] = [];
|
||||
const synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
try {
|
||||
// Fetch Square inventory counts for the location
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
function getSquareBaseUrl(accessToken: string) {
|
||||
return process.env.SQUARE_ENVIRONMENT === "production"
|
||||
@@ -85,8 +85,6 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Determine sync start time — last sync or 30 days ago
|
||||
const since = settings.square_last_sync_at
|
||||
@@ -131,36 +129,48 @@ export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult>
|
||||
// Use idempotency key to avoid duplicates
|
||||
const idempotencyKey = `square_${payment.id}`;
|
||||
|
||||
const createRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_customer_name: customerName,
|
||||
p_customer_email: customerEmail,
|
||||
p_customer_phone: "",
|
||||
p_stop_id: null, // Square orders don't have RC stop_id
|
||||
p_items: lineItems.map((li: { name: string; quantity: number; price: number }) => ({
|
||||
id: null, // product lookup not available in this flow
|
||||
quantity: li.quantity,
|
||||
fulfillment: "shipping",
|
||||
})),
|
||||
p_subtotal: total,
|
||||
p_payment_processor: "square",
|
||||
p_payment_status: "paid",
|
||||
p_payment_transaction_id: payment.id,
|
||||
}),
|
||||
// Call SECURITY DEFINER RPC create_order_with_items
|
||||
let createOk = false;
|
||||
let errText = "";
|
||||
try {
|
||||
const rpcRes = await pool.query(
|
||||
`SELECT * FROM create_order_with_items(
|
||||
$1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10
|
||||
)`,
|
||||
[
|
||||
idempotencyKey,
|
||||
customerName,
|
||||
customerEmail,
|
||||
"",
|
||||
null, // Square orders don't have RC stop_id
|
||||
JSON.stringify(
|
||||
lineItems.map((li: { name: string; quantity: number; price: number }) => ({
|
||||
id: null, // product lookup not available in this flow
|
||||
quantity: li.quantity,
|
||||
fulfillment: "shipping",
|
||||
}))
|
||||
),
|
||||
total,
|
||||
"square",
|
||||
"paid",
|
||||
payment.id,
|
||||
]
|
||||
);
|
||||
createOk = rpcRes.rows.length > 0;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// 409 / unique violation = already exists (idempotent)
|
||||
if (/duplicate key|unique constraint|already exists/i.test(msg)) {
|
||||
createOk = true;
|
||||
} else {
|
||||
errText = msg.slice(0, 100);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (createRes.ok || createRes.status === 409) {
|
||||
// 409 = already exists (idempotent)
|
||||
if (createOk) {
|
||||
synced++;
|
||||
} else {
|
||||
const errText = await createRes.text();
|
||||
errors.push(`Payment ${payment.id}: ${errText.slice(0, 100)}`);
|
||||
errors.push(`Payment ${payment.id}: ${errText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`Payment ${payment.id}: ${String(err)}`);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type SquareCatalogItem = {
|
||||
id: string;
|
||||
@@ -135,25 +135,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
|
||||
let synced = 0;
|
||||
|
||||
try {
|
||||
// Fetch wholesale products via RPC (avoids rc_product_id bug in direct query)
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const rpcRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!rpcRes.ok) {
|
||||
const errText = await rpcRes.text();
|
||||
return { success: false, synced: 0, errors: [`Failed to fetch wholesale products: ${errText}`] };
|
||||
}
|
||||
|
||||
const products: Array<{
|
||||
// Fetch wholesale products via SECURITY DEFINER RPC
|
||||
const rpcRes = await pool.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
@@ -163,7 +146,12 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
|
||||
hp_sku: string | null;
|
||||
hp_item_id: string | null;
|
||||
default_pickup_location: string | null;
|
||||
}> = await rpcRes.json();
|
||||
}>(
|
||||
"SELECT * FROM get_wholesale_products($1)",
|
||||
[brandId]
|
||||
);
|
||||
|
||||
const products = rpcRes.rows;
|
||||
|
||||
// Filter to available products only
|
||||
const availableProducts = products.filter((p) => p.availability === "available");
|
||||
@@ -247,8 +235,6 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
@@ -263,15 +249,13 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
|
||||
const price = priceMoney ? Number(priceMoney.amount) / 100 : 0;
|
||||
const imageUrl = obj.item.image_url ?? null;
|
||||
|
||||
// Sync to RC via bulk_upsert_products RPC
|
||||
const upsertRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_products: [
|
||||
// Sync to RC via SECURITY DEFINER RPC bulk_upsert_products
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT bulk_upsert_products($1, $2::jsonb)",
|
||||
[
|
||||
brandId,
|
||||
JSON.stringify([
|
||||
{
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
@@ -280,15 +264,12 @@ export async function syncProductsFromSquare(brandId: string): Promise<SyncResul
|
||||
active: true,
|
||||
image_url: imageUrl,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertRes.ok) {
|
||||
]),
|
||||
]
|
||||
);
|
||||
synced++;
|
||||
} else {
|
||||
const errText = await upsertRes.text();
|
||||
} catch (e: unknown) {
|
||||
const errText = e instanceof Error ? e.message : String(e);
|
||||
errors.push(`Square item "${item.name}": ${errText.slice(0, 100)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type SyncLogEntry = {
|
||||
id: string;
|
||||
@@ -70,20 +70,10 @@ export async function getSyncLog(brandId: string): Promise<{
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/square_sync_log?brand_id=eq.${brandId}&order=created_at.desc&limit=10`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
const { rows: logs } = await pool.query<SyncLogEntry>(
|
||||
"SELECT * FROM square_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT 10",
|
||||
[brandId]
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, logs: [] };
|
||||
}
|
||||
|
||||
const logs: SyncLogEntry[] = await response.json();
|
||||
return { success: true, logs };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
import Stripe from "stripe";
|
||||
|
||||
/**
|
||||
@@ -19,25 +19,13 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get brand's payment settings
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_brand_payment_settings`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
// Get brand's payment settings via SECURITY DEFINER RPC
|
||||
const { rows } = await pool.query<{ stripe_user_id: string | null }>(
|
||||
"SELECT * FROM get_brand_payment_settings($1)",
|
||||
[brandId]
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return { is_connected: false, error: "Failed to fetch payment settings" };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const stripeUserId = data?.stripe_user_id;
|
||||
const stripeUserId = rows[0]?.stripe_user_id ?? null;
|
||||
|
||||
if (!stripeUserId) {
|
||||
return { is_connected: false };
|
||||
@@ -183,28 +171,17 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Save to payment_settings via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_stripe_connect_account`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_stripe_user_id: accountId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
try {
|
||||
// Save to payment_settings via SECURITY DEFINER RPC
|
||||
await pool.query(
|
||||
"SELECT set_stripe_connect_account($1, $2)",
|
||||
[brandId, accountId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
return { success: false, error: `Failed to save: ${error}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,23 +197,16 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
|
||||
return { success: false, error: "Not authorized" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/disconnect_stripe_connect`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
// SECURITY DEFINER RPC disconnects the Stripe Connect account
|
||||
await pool.query(
|
||||
"SELECT disconnect_stripe_connect($1)",
|
||||
[brandId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to disconnect Stripe account" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type WholesaleLoginResult =
|
||||
| { success: true; token: string; userId: string; customerId: string }
|
||||
@@ -54,21 +54,17 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
|
||||
return { success: false, error: "No session returned from auth" };
|
||||
}
|
||||
|
||||
// Find the wholesale customer record for this user
|
||||
const customerRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseAnonKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: "placeholder", // will use any-brand lookup below
|
||||
p_user_id: data.user.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Find the wholesale customer record for this user (SECURITY DEFINER RPC).
|
||||
// The result is intentionally not consumed here — the portal page resolves
|
||||
// the actual customer on load using the cookie's access_token.
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
|
||||
["00000000-0000-0000-0000-000000000000", data.user.id]
|
||||
);
|
||||
} catch {
|
||||
// Customer may not be linked yet; portal will resolve.
|
||||
}
|
||||
|
||||
// If no brand_id known, try all brands — just use first active one found
|
||||
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
|
||||
|
||||
+177
-246
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { assertBrandAccess } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export async function registerWholesaleCustomer(params: {
|
||||
brandId: string;
|
||||
@@ -11,43 +11,33 @@ export async function registerWholesaleCustomer(params: {
|
||||
email: string;
|
||||
phone?: string;
|
||||
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_company_name: params.companyName,
|
||||
p_contact_name: params.contactName ?? null,
|
||||
p_email: params.email,
|
||||
p_phone: params.phone ?? null,
|
||||
}),
|
||||
try {
|
||||
const { rows } = await pool.query<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
requires_approval?: boolean;
|
||||
}>(
|
||||
`SELECT * FROM register_wholesale_customer($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
params.brandId,
|
||||
params.companyName,
|
||||
params.contactName ?? null,
|
||||
params.email,
|
||||
params.phone ?? null,
|
||||
]
|
||||
);
|
||||
const result = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!result?.success) {
|
||||
return { success: false, error: result?.error ?? "Registration failed." };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
// Supabase error format: { "message": "...", "code": "...", ... }
|
||||
// Our RPC error format: { "success": false, "error": "..." }
|
||||
return { success: false, error: err.message ?? err.error ?? "Registration failed." };
|
||||
return {
|
||||
success: true,
|
||||
requiresApproval: result.requires_approval ?? false,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const err = e instanceof Error ? e.message : String(e);
|
||||
return { success: false, error: err || "Registration failed." };
|
||||
}
|
||||
const data = await response.json();
|
||||
// Normalize: RPC may return an array (single row) or a plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error ?? "Registration failed." };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
requiresApproval: result.requires_approval ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPendingWholesaleRegistrations(brandId: string) {
|
||||
@@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
|
||||
if (!adminUser) return [];
|
||||
if (!adminUser.can_manage_orders) return [];
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM get_pending_wholesale_registrations($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveWholesaleRegistration(
|
||||
@@ -88,33 +70,19 @@ export async function approveWholesaleRegistration(
|
||||
return { success: false, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_registration_id: registrationId,
|
||||
p_brand_id: brandId,
|
||||
p_action: action,
|
||||
}),
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM approve_wholesale_registration($1, $2, $3)",
|
||||
[registrationId, brandId, action]
|
||||
);
|
||||
const result = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!result?.success) {
|
||||
return { success: false, error: result?.error ?? "Failed to process registration." };
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to process registration." };
|
||||
const data = await response.json();
|
||||
// Normalize array response (RETURNING single row) to plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error ?? "Failed to process registration." };
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getWholesaleCustomerByUser(
|
||||
@@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser(
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data ?? null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
|
||||
[brandId, userId]
|
||||
);
|
||||
return (rows[0] as {
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
account_status: string;
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | undefined) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
|
||||
@@ -165,40 +134,41 @@ export async function getWholesaleCustomer(
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`,
|
||||
{
|
||||
headers: svcHeaders(supabaseKey),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data?.[0] ?? null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
|
||||
FROM wholesale_customers
|
||||
WHERE id = $1
|
||||
LIMIT 1`,
|
||||
[customerId]
|
||||
);
|
||||
return (rows[0] as {
|
||||
id: string;
|
||||
user_id: string;
|
||||
company_name: string;
|
||||
contact_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
account_status: string;
|
||||
role: string;
|
||||
brand_id: string;
|
||||
} | undefined) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleProducts(brandId: string) {
|
||||
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_brand_id: brandId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM get_wholesale_products($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitWholesaleOrder(params: {
|
||||
@@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: {
|
||||
orderTotal?: number;
|
||||
idempotent?: boolean;
|
||||
}> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Generate UUID at the start of checkout — before RPC call for true idempotency
|
||||
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
|
||||
|
||||
@@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: {
|
||||
unit_price: i.unit_price,
|
||||
}));
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: params.brandId,
|
||||
p_customer_id: params.customerId,
|
||||
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null,
|
||||
p_items: itemsJson,
|
||||
p_notes: params.notes ?? null,
|
||||
p_checkout_session_id: checkoutSessionId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
let result: {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
order_id?: string;
|
||||
invoice_number?: string | null;
|
||||
status?: string;
|
||||
deposit_required?: number;
|
||||
credit_limit?: number;
|
||||
outstanding_balance?: number;
|
||||
order_total?: number;
|
||||
idempotent?: boolean;
|
||||
} | null = null;
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to create order." };
|
||||
const data = await response.json();
|
||||
// Normalize array response (RETURNING single row) to plain object
|
||||
const result = Array.isArray(data) ? data[0] : data;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`,
|
||||
[
|
||||
params.brandId,
|
||||
params.customerId,
|
||||
params.anticipatedPickupDate ?? null,
|
||||
JSON.stringify(itemsJson),
|
||||
params.notes ?? null,
|
||||
checkoutSessionId,
|
||||
]
|
||||
);
|
||||
result = Array.isArray(rows) ? rows[0] : rows;
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to create order." };
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (!result?.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error ?? "Failed to create order.",
|
||||
creditLimit: result.credit_limit,
|
||||
outstandingBalance: result.outstanding_balance,
|
||||
orderTotal: result.order_total,
|
||||
error: result?.error ?? "Failed to create order.",
|
||||
creditLimit: result?.credit_limit,
|
||||
outstandingBalance: result?.outstanding_balance,
|
||||
orderTotal: result?.order_total,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire webhook — fire-and-forget, don't block the response
|
||||
enqueueWholesaleWebhookForOrderCreated(
|
||||
result.order_id,
|
||||
result.order_id!,
|
||||
params.brandId,
|
||||
params.customerId,
|
||||
result.invoice_number,
|
||||
result.invoice_number ?? null,
|
||||
Number(result.deposit_required) || 0
|
||||
).catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: result.order_id,
|
||||
invoiceNumber: result.invoice_number,
|
||||
invoiceNumber: result.invoice_number ?? undefined,
|
||||
status: result.status,
|
||||
depositRequired: result.deposit_required,
|
||||
idempotent: result.idempotent ?? false,
|
||||
@@ -287,18 +260,15 @@ export async function submitWholesaleOrder(params: {
|
||||
|
||||
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "order_created",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
|
||||
}),
|
||||
});
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
||||
[
|
||||
"order_created",
|
||||
orderId,
|
||||
brandId,
|
||||
JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }),
|
||||
]
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -344,23 +314,15 @@ export type WholesalePricingOverride = {
|
||||
};
|
||||
|
||||
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_customer_id: customerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM get_wholesale_customer_pricing($1)",
|
||||
[customerId]
|
||||
);
|
||||
return rows as WholesalePricingOverride[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertWholesaleCustomerPricing(params: {
|
||||
@@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: {
|
||||
productId: string;
|
||||
customUnitPrice: number;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_customer_id: params.customerId,
|
||||
p_product_id: params.productId,
|
||||
p_custom_unit_price: params.customUnitPrice,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
|
||||
return { success: true };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
|
||||
[params.customerId, params.productId, params.customUnitPrice]
|
||||
);
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleCustomerPricing(params: {
|
||||
customerId: string;
|
||||
productId: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_customer_id: params.customerId,
|
||||
p_product_id: params.productId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
|
||||
return { success: true };
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT delete_wholesale_customer_pricing($1, $2)",
|
||||
[params.customerId, params.productId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ p_customer_id: customerId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) return [];
|
||||
return response.json();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM get_wholesale_customer_orders($1)",
|
||||
[customerId]
|
||||
);
|
||||
return rows as WholesaleCustomerOrder[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+335
-520
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user