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

This commit is contained in:
2026-06-07 03:25:22 +00:00
parent eb9621d238
commit 99a3d66636
26 changed files with 1216 additions and 1776 deletions
+177 -246
View File
@@ -2,7 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function registerWholesaleCustomer(params: {
brandId: string;
@@ -11,43 +11,33 @@ export async function registerWholesaleCustomer(params: {
email: string;
phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/register_wholesale_customer`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: params.brandId,
p_company_name: params.companyName,
p_contact_name: params.contactName ?? null,
p_email: params.email,
p_phone: params.phone ?? null,
}),
try {
const { rows } = await pool.query<{
success: boolean;
error?: string;
requires_approval?: boolean;
}>(
`SELECT * FROM register_wholesale_customer($1, $2, $3, $4, $5)`,
[
params.brandId,
params.companyName,
params.contactName ?? null,
params.email,
params.phone ?? null,
]
);
const result = Array.isArray(rows) ? rows[0] : rows;
if (!result?.success) {
return { success: false, error: result?.error ?? "Registration failed." };
}
);
if (!response.ok) {
const err = await response.json();
// Supabase error format: { "message": "...", "code": "...", ... }
// Our RPC error format: { "success": false, "error": "..." }
return { success: false, error: err.message ?? err.error ?? "Registration failed." };
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
} catch (e: unknown) {
const err = e instanceof Error ? e.message : String(e);
return { success: false, error: err || "Registration failed." };
}
const data = await response.json();
// Normalize: RPC may return an array (single row) or a plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Registration failed." };
}
return {
success: true,
requiresApproval: result.requires_approval ?? false,
};
}
export async function getPendingWholesaleRegistrations(brandId: string) {
@@ -55,23 +45,15 @@ export async function getPendingWholesaleRegistrations(brandId: string) {
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_pending_wholesale_registrations`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_pending_wholesale_registrations($1)",
[brandId]
);
return rows;
} catch {
return [];
}
}
export async function approveWholesaleRegistration(
@@ -88,33 +70,19 @@ export async function approveWholesaleRegistration(
return { success: false, error: "Not authorized to operate on this brand" };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/approve_wholesale_registration`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_registration_id: registrationId,
p_brand_id: brandId,
p_action: action,
}),
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM approve_wholesale_registration($1, $2, $3)",
[registrationId, brandId, action]
);
const result = Array.isArray(rows) ? rows[0] : rows;
if (!result?.success) {
return { success: false, error: result?.error ?? "Failed to process registration." };
}
);
if (!response.ok) return { success: false, error: "Failed to process registration." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
if (!result.success) {
return { success: false, error: result.error ?? "Failed to process registration." };
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to process registration." };
}
return { success: true };
}
export async function getWholesaleCustomerByUser(
@@ -131,24 +99,25 @@ export async function getWholesaleCustomerByUser(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId, p_user_id: userId }),
}
);
if (!response.ok) return null;
const data = await response.json();
return data ?? null;
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
[brandId, userId]
);
return (rows[0] as {
id: string;
user_id: string;
company_name: string;
contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
}
// Fetch a wholesale customer directly by their customer ID (used for admin preview mode)
@@ -165,40 +134,41 @@ export async function getWholesaleCustomer(
role: string;
brand_id: string;
} | null> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,user_id,company_name,contact_name,email,phone,account_status,role,brand_id`,
{
headers: svcHeaders(supabaseKey),
}
);
if (!response.ok) return null;
const data = await response.json();
return data?.[0] ?? null;
try {
const { rows } = await pool.query(
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
FROM wholesale_customers
WHERE id = $1
LIMIT 1`,
[customerId]
);
return (rows[0] as {
id: string;
user_id: string;
company_name: string;
contact_name: string;
email: string;
phone: string;
account_status: string;
role: string;
brand_id: string;
} | undefined) ?? null;
} catch {
return null;
}
}
export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_products`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
[brandId]
);
return rows;
} catch {
return [];
}
}
export async function submitWholesaleOrder(params: {
@@ -220,9 +190,6 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number;
idempotent?: boolean;
}> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
@@ -232,53 +199,59 @@ export async function submitWholesaleOrder(params: {
unit_price: i.unit_price,
}));
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/create_wholesale_order`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: params.brandId,
p_customer_id: params.customerId,
p_anticipated_pickup_date: params.anticipatedPickupDate ?? null,
p_items: itemsJson,
p_notes: params.notes ?? null,
p_checkout_session_id: checkoutSessionId,
}),
}
);
let result: {
success?: boolean;
error?: string;
order_id?: string;
invoice_number?: string | null;
status?: string;
deposit_required?: number;
credit_limit?: number;
outstanding_balance?: number;
order_total?: number;
idempotent?: boolean;
} | null = null;
if (!response.ok) return { success: false, error: "Failed to create order." };
const data = await response.json();
// Normalize array response (RETURNING single row) to plain object
const result = Array.isArray(data) ? data[0] : data;
try {
const { rows } = await pool.query(
`SELECT * FROM create_wholesale_order($1, $2, $3, $4::jsonb, $5, $6)`,
[
params.brandId,
params.customerId,
params.anticipatedPickupDate ?? null,
JSON.stringify(itemsJson),
params.notes ?? null,
checkoutSessionId,
]
);
result = Array.isArray(rows) ? rows[0] : rows;
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to create order." };
}
if (!result.success) {
if (!result?.success) {
return {
success: false,
error: result.error ?? "Failed to create order.",
creditLimit: result.credit_limit,
outstandingBalance: result.outstanding_balance,
orderTotal: result.order_total,
error: result?.error ?? "Failed to create order.",
creditLimit: result?.credit_limit,
outstandingBalance: result?.outstanding_balance,
orderTotal: result?.order_total,
};
}
// Fire webhook — fire-and-forget, don't block the response
enqueueWholesaleWebhookForOrderCreated(
result.order_id,
result.order_id!,
params.brandId,
params.customerId,
result.invoice_number,
result.invoice_number ?? null,
Number(result.deposit_required) || 0
).catch(() => {});
return {
success: true,
orderId: result.order_id,
invoiceNumber: result.invoice_number,
invoiceNumber: result.invoice_number ?? undefined,
status: result.status,
depositRequired: result.deposit_required,
idempotent: result.idempotent ?? false,
@@ -287,18 +260,15 @@ export async function submitWholesaleOrder(params: {
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? process.env.SUPABASE_ANON_KEY!;
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_event_type: "order_created",
p_order_id: orderId,
p_brand_id: brandId,
p_payload: { order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal },
}),
});
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"order_created",
orderId,
brandId,
JSON.stringify({ order_id: orderId, brand_id: brandId, customer_id: customerId, invoice_number: invoiceNumber, subtotal }),
]
);
} catch (_) {}
}
@@ -344,23 +314,15 @@ export type WholesalePricingOverride = {
};
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
[customerId]
);
return rows as WholesalePricingOverride[];
} catch {
return [];
}
}
export async function upsertWholesaleCustomerPricing(params: {
@@ -368,71 +330,40 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string;
customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
p_custom_unit_price: params.customUnitPrice,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to save pricing override" };
return { success: true };
try {
await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
[params.customerId, params.productId, params.customUnitPrice]
);
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to save pricing override" };
}
}
export async function deleteWholesaleCustomerPricing(params: {
customerId: string;
productId: string;
}): Promise<{ success: boolean; error?: string }> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/delete_wholesale_customer_pricing`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_customer_id: params.customerId,
p_product_id: params.productId,
}),
}
);
if (!response.ok) return { success: false, error: "Failed to delete pricing override" };
return { success: true };
try {
await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
[params.customerId, params.productId]
);
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete pricing override" };
}
}
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_orders`,
{
method: "POST",
headers: {
...svcHeaders(supabaseKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_customer_id: customerId }),
}
);
if (!response.ok) return [];
return response.json();
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
[customerId]
);
return rows as WholesaleCustomerOrder[];
} catch {
return [];
}
}