migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)
This commit is contained in:
+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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user