refactor(wholesale): split monolithic wholesale.ts into focused modules
The wholesale action set was a single 887-line file mixing 8 distinct concerns: shared types, brand-scoping helpers, order CRUD, customer CRUD, product CRUD, settings, deposits, notifications, and webhooks. Split into a wholesale/ directory with one file per concern plus a barrel re-export for backwards compatibility. New structure: - wholesale/types.ts — type definitions (no "use server") - wholesale/scope.ts — resolveBrandId, enforceBrandScope helpers - wholesale/orders.ts — order CRUD + dashboard stats (127 lines) - wholesale/customers.ts — customer CRUD (94) - wholesale/products.ts — product CRUD (100) - wholesale/settings.ts — wholesale_settings + public read (86) - wholesale/deposits.ts — deposits + bulk actions (84) - wholesale/notifications.ts — email/SMS notification queue (88) - wholesale/webhooks.ts — outbound webhook settings + dispatch (90) - wholesale/index.ts — barrel re-export (77 lines) Each module is now under 155 lines and topically cohesive. Existing imports of `@/actions/wholesale` keep working unchanged because of the barrel. New code can import directly from the focused module to keep its dep graph smaller. Notable small cleanups inside the split: - Removed two private helpers that enqueued webhooks by calling the RPC directly; consolidated on the public enqueueWholesaleWebhook export from webhooks.ts so orders.ts and deposits.ts no longer need fire-and-forget duplication. - recordWholesaleDeposit now correctly accepts the brandId parameter (the original signature lacked it despite the docstring implying it). Verified: tsc clean, 175/175 tests pass, dev server still compiles all routes. /wholesale/portal returns 500 due to a pre-existing brand-scope.ts "server-only" issue in the Pages Router chain — not introduced by this commit.
This commit is contained in:
@@ -1,888 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
export type WholesaleOrder = {
|
|
||||||
id: string;
|
|
||||||
customer_id: string;
|
|
||||||
status: string;
|
|
||||||
fulfillment_status: string;
|
|
||||||
payment_status: string;
|
|
||||||
anticipated_pickup_date: string | null;
|
|
||||||
subtotal: number;
|
|
||||||
deposit_required: number;
|
|
||||||
deposit_paid: number;
|
|
||||||
balance_due: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
invoice_number: string | null;
|
|
||||||
assigned_employee_id: string | null;
|
|
||||||
company_name: string;
|
|
||||||
contact_name: string | null;
|
|
||||||
customer_email: string;
|
|
||||||
customer_phone: string | null;
|
|
||||||
items: Array<{
|
|
||||||
id: string;
|
|
||||||
product_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
line_total: number;
|
|
||||||
}>;
|
|
||||||
fulfilled_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleCustomer = {
|
|
||||||
id: string;
|
|
||||||
user_id: string | null;
|
|
||||||
company_name: string;
|
|
||||||
contact_name: string | null;
|
|
||||||
email: string;
|
|
||||||
phone: string | null;
|
|
||||||
account_status: string;
|
|
||||||
credit_limit: number;
|
|
||||||
deposits_enabled: boolean;
|
|
||||||
deposit_threshold: number | null;
|
|
||||||
deposit_percentage: number | null;
|
|
||||||
order_email: string | null;
|
|
||||||
invoice_email: string | null;
|
|
||||||
admin_notes: string | null;
|
|
||||||
role: string;
|
|
||||||
created_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleProduct = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
unit_type: string;
|
|
||||||
unit_type_custom: string | null;
|
|
||||||
availability: string;
|
|
||||||
qty_available: number;
|
|
||||||
season_start: string | null;
|
|
||||||
season_end: string | null;
|
|
||||||
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
|
||||||
hp_sku: string | null;
|
|
||||||
hp_item_id: string | null;
|
|
||||||
handling_instructions: string | null;
|
|
||||||
storage_warning: string | null;
|
|
||||||
loading_notes: string | null;
|
|
||||||
product_label: string | null;
|
|
||||||
pack_style: string | null;
|
|
||||||
container_type: string | null;
|
|
||||||
container_size_code: string | null;
|
|
||||||
units_per_container: number | null;
|
|
||||||
default_pickup_location: string | null;
|
|
||||||
created_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NotificationRecipient = {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
active: boolean;
|
|
||||||
notification_types?: (
|
|
||||||
| "order_confirmation"
|
|
||||||
| "deposit_received"
|
|
||||||
| "order_fulfilled"
|
|
||||||
| "price_sheet"
|
|
||||||
| "unclaimed_pickup"
|
|
||||||
)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleSettings = {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
portal_page_id: string | null;
|
|
||||||
price_sheet_page_id: string | null;
|
|
||||||
require_approval: boolean;
|
|
||||||
min_order_amount: number | null;
|
|
||||||
online_payment_enabled: boolean;
|
|
||||||
wholesale_enabled: boolean;
|
|
||||||
square_sync_enabled: boolean;
|
|
||||||
pickup_location: string;
|
|
||||||
fob_location: string;
|
|
||||||
from_email: string;
|
|
||||||
invoice_business_name: string;
|
|
||||||
invoice_business_address: string | null;
|
|
||||||
invoice_business_phone: string | null;
|
|
||||||
invoice_business_email: string | null;
|
|
||||||
invoice_business_website: string | null;
|
|
||||||
notification_email: string | null;
|
|
||||||
notification_recipients: NotificationRecipient[];
|
|
||||||
last_invoice_number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleDashboardStats = {
|
|
||||||
open_orders: number;
|
|
||||||
pickup_today: number;
|
|
||||||
past_due: number;
|
|
||||||
total_unpaid: number;
|
|
||||||
awaiting_deposit: number;
|
|
||||||
fulfilled_today: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
|
||||||
*
|
|
||||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
|
||||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
|
||||||
* store_employee → their own brand_id
|
|
||||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
|
||||||
* unauthenticated → null (actions should already bail out earlier)
|
|
||||||
*
|
|
||||||
* This prevents brand_admin from seeing or modifying another brand's data
|
|
||||||
* even if they manually pass a different brandId to the action.
|
|
||||||
*/
|
|
||||||
async function resolveBrandId(
|
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
|
||||||
requestedBrandId?: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!adminUser) return null;
|
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
|
||||||
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
|
||||||
|
|
||||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
|
||||||
// Brand admin trying to operate on another brand's data — block it
|
|
||||||
return null; // caller should check and return unauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeBrandId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
|
||||||
* if a brand_admin tries to operate outside their brand.
|
|
||||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
|
||||||
*/
|
|
||||||
async function enforceBrandScope(
|
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
|
||||||
requestedBrandId?: string
|
|
||||||
): Promise<{ brandId: string | null; error?: string }> {
|
|
||||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
|
||||||
return { brandId: null }; // unrestricted
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
|
||||||
|
|
||||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
|
||||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { brandId: activeBrandId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleOrder>(
|
|
||||||
"SELECT * FROM get_wholesale_orders($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleOrder>(
|
|
||||||
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
|
||||||
const orders = await getWholesaleOrders(brandId);
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
|
|
||||||
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
|
||||||
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
|
||||||
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
|
||||||
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
|
||||||
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
|
||||||
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
|
||||||
|
|
||||||
return {
|
|
||||||
open_orders: open.length,
|
|
||||||
pickup_today: pickupToday.length,
|
|
||||||
past_due: pastDue.length,
|
|
||||||
total_unpaid: totalUnpaid,
|
|
||||||
awaiting_deposit: awaitingDep.length,
|
|
||||||
fulfilled_today: fulfilledToday.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
|
||||||
[orderId, adminUser.user_id]
|
|
||||||
);
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
return { success: false, error: "Failed to mark fulfilled" };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to mark fulfilled" };
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
|
||||||
[
|
|
||||||
"order_fulfilled",
|
|
||||||
orderId,
|
|
||||||
brandId ?? null,
|
|
||||||
JSON.stringify({ order_id: orderId }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWholesaleOrderStatus(
|
|
||||||
orderId: string,
|
|
||||||
status: "pending" | "confirmed" | "cancelled",
|
|
||||||
brandId?: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
|
||||||
[orderId, status]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to update order status" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT * FROM delete_wholesale_order($1)",
|
|
||||||
[orderId]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to delete order" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM delete_wholesale_customer($1)",
|
|
||||||
[customerId]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Delete failed" };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM delete_wholesale_product($1)",
|
|
||||||
[productId]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Delete failed" };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Customers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleCustomer>(
|
|
||||||
"SELECT * FROM get_wholesale_customers($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleCustomer(params: {
|
|
||||||
brandId: string;
|
|
||||||
userId?: string;
|
|
||||||
companyName?: string;
|
|
||||||
contactName?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
accountStatus?: string;
|
|
||||||
creditLimit?: number;
|
|
||||||
depositsEnabled?: boolean;
|
|
||||||
depositThreshold?: number;
|
|
||||||
depositPercentage?: number;
|
|
||||||
orderEmail?: string;
|
|
||||||
invoiceEmail?: string;
|
|
||||||
adminNotes?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.userId ?? null,
|
|
||||||
params.companyName ?? null,
|
|
||||||
params.contactName ?? null,
|
|
||||||
params.email ?? null,
|
|
||||||
params.phone ?? null,
|
|
||||||
params.accountStatus ?? "active",
|
|
||||||
params.creditLimit ?? 0,
|
|
||||||
params.depositsEnabled ?? false,
|
|
||||||
params.depositThreshold ?? null,
|
|
||||||
params.depositPercentage ?? null,
|
|
||||||
params.orderEmail ?? null,
|
|
||||||
params.invoiceEmail ?? null,
|
|
||||||
params.adminNotes ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, id: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save customer" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Products ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleProduct>(
|
|
||||||
"SELECT * FROM get_wholesale_products($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleProduct(params: {
|
|
||||||
brandId: string;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
unitType?: string;
|
|
||||||
availability?: string;
|
|
||||||
qtyAvailable?: number;
|
|
||||||
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
|
||||||
hpSku?: string;
|
|
||||||
hpItemId?: string;
|
|
||||||
internalNotes?: string;
|
|
||||||
handlingInstructions?: string;
|
|
||||||
storageWarning?: string;
|
|
||||||
productLabel?: string;
|
|
||||||
packStyle?: string;
|
|
||||||
containerType?: string;
|
|
||||||
defaultPickupLocation?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.id ?? null,
|
|
||||||
params.name,
|
|
||||||
params.description ?? null,
|
|
||||||
params.unitType ?? "each",
|
|
||||||
params.availability ?? "unavailable",
|
|
||||||
params.qtyAvailable ?? 0,
|
|
||||||
JSON.stringify(params.priceTiers ?? []),
|
|
||||||
params.hpSku ?? null,
|
|
||||||
params.hpItemId ?? null,
|
|
||||||
params.internalNotes ?? null,
|
|
||||||
params.handlingInstructions ?? null,
|
|
||||||
params.storageWarning ?? null,
|
|
||||||
params.productLabel ?? null,
|
|
||||||
params.packStyle ?? null,
|
|
||||||
params.containerType ?? null,
|
|
||||||
params.defaultPickupLocation ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, id: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save product" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return null;
|
|
||||||
const bid = await getActiveBrandId(adminUser, brandId);
|
|
||||||
if (!bid && adminUser.role !== "platform_admin") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleSettings>(
|
|
||||||
"SELECT * FROM get_wholesale_settings($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
|
||||||
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleSettings(params: {
|
|
||||||
brandId: string;
|
|
||||||
requireApproval?: boolean;
|
|
||||||
minOrderAmount?: number;
|
|
||||||
onlinePaymentEnabled?: boolean;
|
|
||||||
wholesaleEnabled?: boolean;
|
|
||||||
squareSyncEnabled?: boolean;
|
|
||||||
pickupLocation?: string;
|
|
||||||
fobLocation?: string;
|
|
||||||
fromEmail?: string;
|
|
||||||
invoiceBusinessName?: string;
|
|
||||||
invoiceBusinessAddress?: string;
|
|
||||||
invoiceBusinessPhone?: string;
|
|
||||||
invoiceBusinessEmail?: string;
|
|
||||||
invoiceBusinessWebsite?: string;
|
|
||||||
notificationEmail?: string;
|
|
||||||
notificationRecipients?: NotificationRecipient[];
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.requireApproval ?? null,
|
|
||||||
params.minOrderAmount ?? null,
|
|
||||||
params.onlinePaymentEnabled ?? null,
|
|
||||||
params.wholesaleEnabled ?? null,
|
|
||||||
params.pickupLocation ?? null,
|
|
||||||
params.fobLocation ?? null,
|
|
||||||
params.fromEmail ?? null,
|
|
||||||
params.invoiceBusinessName ?? null,
|
|
||||||
params.invoiceBusinessAddress ?? null,
|
|
||||||
params.invoiceBusinessPhone ?? null,
|
|
||||||
params.invoiceBusinessEmail ?? null,
|
|
||||||
params.invoiceBusinessWebsite ?? null,
|
|
||||||
params.notificationEmail ?? null,
|
|
||||||
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
|
||||||
params.squareSyncEnabled ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save settings" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deposits ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function recordWholesaleDeposit(
|
|
||||||
orderId: string,
|
|
||||||
amount: number,
|
|
||||||
method: string = "cash",
|
|
||||||
reference?: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser);
|
|
||||||
let data: { success?: boolean; error?: string } | null = null;
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
|
||||||
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
|
||||||
);
|
|
||||||
data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire webhook — fire-and-forget
|
|
||||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
|
||||||
[
|
|
||||||
"deposit_recorded",
|
|
||||||
orderId,
|
|
||||||
brandId ?? null,
|
|
||||||
JSON.stringify({ order_id: orderId, amount }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bulk Actions ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function bulkFulfillWholesaleOrders(
|
|
||||||
orderIds: string[]
|
|
||||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
|
||||||
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
|
||||||
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
|
||||||
}
|
|
||||||
return { success: true, count: data.count };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkRecordWholesaleDeposit(
|
|
||||||
orderIds: string[],
|
|
||||||
amount: number,
|
|
||||||
method: string = "cash",
|
|
||||||
reference?: string
|
|
||||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
|
||||||
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
|
||||||
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
|
||||||
}
|
|
||||||
return { success: true, count: data.count };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WholesaleNotification = {
|
|
||||||
id: string;
|
|
||||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
|
||||||
email_to: string;
|
|
||||||
email_cc: string | null;
|
|
||||||
subject: string;
|
|
||||||
body_html: string | null;
|
|
||||||
body_text: string | null;
|
|
||||||
brand_id: string;
|
|
||||||
customer_id: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
invoice_business_name: string | null;
|
|
||||||
invoice_business_email: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getWholesaleNotificationStats(
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
|
||||||
"SELECT * FROM get_wholesale_notification_stats($1)",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
|
||||||
} catch {
|
|
||||||
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesalePendingNotifications(
|
|
||||||
brandId: string,
|
|
||||||
limit = 50
|
|
||||||
): Promise<WholesaleNotification[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
if (!adminUser.can_manage_orders) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleNotification>(
|
|
||||||
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
|
||||||
[brandId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markWholesaleNotificationSent(
|
|
||||||
notificationId: string,
|
|
||||||
error?: string
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT mark_wholesale_notification_sent($1, $2)",
|
|
||||||
[notificationId, error ?? null]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueWholesaleNotification(params: {
|
|
||||||
brandId: string;
|
|
||||||
customerId: string;
|
|
||||||
orderId: string;
|
|
||||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
|
||||||
emailTo: string;
|
|
||||||
emailCc?: string;
|
|
||||||
subject: string;
|
|
||||||
bodyHtml?: string;
|
|
||||||
bodyText?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.customerId,
|
|
||||||
params.orderId,
|
|
||||||
params.type,
|
|
||||||
params.emailTo,
|
|
||||||
params.emailCc ?? null,
|
|
||||||
params.subject,
|
|
||||||
params.bodyHtml ?? null,
|
|
||||||
params.bodyText ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to enqueue notification" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Webhooks ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WebhookSettings = {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
url: string;
|
|
||||||
secret: string;
|
|
||||||
enabled: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WebhookSettings>(
|
|
||||||
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWebhookSettings(params: {
|
|
||||||
brandId: string;
|
|
||||||
url?: string;
|
|
||||||
secret?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
brand_id: params.brandId,
|
|
||||||
url: params.url ?? "",
|
|
||||||
secret: params.secret ?? "",
|
|
||||||
enabled: params.enabled ?? false,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save webhook settings" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueWholesaleWebhook(
|
|
||||||
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
|
||||||
orderId: string | null = null,
|
|
||||||
payload: Record<string, unknown> | null = null,
|
|
||||||
brandId?: string
|
|
||||||
): Promise<{ success: boolean; logId?: string }> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
|
||||||
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, logId: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
|
||||||
id: string;
|
|
||||||
event_type: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
attempts: number;
|
|
||||||
created_at: string;
|
|
||||||
response: string | null;
|
|
||||||
}>> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{
|
|
||||||
id: string;
|
|
||||||
event_type: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
attempts: number;
|
|
||||||
created_at: string;
|
|
||||||
response: string | null;
|
|
||||||
}>(
|
|
||||||
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
|
||||||
[brandId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import type { WholesaleCustomer } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleCustomer>(
|
||||||
|
"SELECT * FROM get_wholesale_customers($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleCustomer(params: {
|
||||||
|
brandId: string;
|
||||||
|
userId?: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
accountStatus?: string;
|
||||||
|
creditLimit?: number;
|
||||||
|
depositsEnabled?: boolean;
|
||||||
|
depositThreshold?: number;
|
||||||
|
depositPercentage?: number;
|
||||||
|
orderEmail?: string;
|
||||||
|
invoiceEmail?: string;
|
||||||
|
adminNotes?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.userId ?? null,
|
||||||
|
params.companyName ?? null,
|
||||||
|
params.contactName ?? null,
|
||||||
|
params.email ?? null,
|
||||||
|
params.phone ?? null,
|
||||||
|
params.accountStatus ?? "active",
|
||||||
|
params.creditLimit ?? 0,
|
||||||
|
params.depositsEnabled ?? false,
|
||||||
|
params.depositThreshold ?? null,
|
||||||
|
params.depositPercentage ?? null,
|
||||||
|
params.orderEmail ?? null,
|
||||||
|
params.invoiceEmail ?? null,
|
||||||
|
params.adminNotes ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, id: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save customer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM delete_wholesale_customer($1)",
|
||||||
|
[customerId]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||||
|
|
||||||
|
export async function recordWholesaleDeposit(
|
||||||
|
orderId: string,
|
||||||
|
amount: number,
|
||||||
|
method: string = "cash",
|
||||||
|
reference?: string,
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
let data: { success?: boolean; error?: string } | null = null;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||||
|
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
||||||
|
);
|
||||||
|
data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire webhook — fire-and-forget
|
||||||
|
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkFulfillWholesaleOrders(
|
||||||
|
orderIds: string[]
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
||||||
|
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
||||||
|
}
|
||||||
|
return { success: true, count: data.count };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkRecordWholesaleDeposit(
|
||||||
|
orderIds: string[],
|
||||||
|
amount: number,
|
||||||
|
method: string = "cash",
|
||||||
|
reference?: string
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||||
|
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
||||||
|
}
|
||||||
|
return { success: true, count: data.count };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Barrel re-export for the wholesale action set.
|
||||||
|
*
|
||||||
|
* Source modules live next to this file:
|
||||||
|
* - orders.ts — order CRUD + dashboard stats
|
||||||
|
* - customers.ts — wholesale customer CRUD
|
||||||
|
* - products.ts — wholesale product CRUD
|
||||||
|
* - settings.ts — wholesale_settings + public read
|
||||||
|
* - deposits.ts — deposit recording + bulk actions
|
||||||
|
* - notifications.ts — email/SMS notification queue
|
||||||
|
* - webhooks.ts — outbound webhook settings + dispatch
|
||||||
|
* - scope.ts — brand-scoping helpers (resolveBrandId, enforceBrandScope)
|
||||||
|
* - types.ts — shared type definitions
|
||||||
|
*
|
||||||
|
* Existing imports of `@/actions/wholesale` keep working. New code should
|
||||||
|
* import directly from the focused modules to keep the dependency graph
|
||||||
|
* tight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
WholesaleOrder,
|
||||||
|
WholesaleCustomer,
|
||||||
|
WholesaleProduct,
|
||||||
|
NotificationRecipient,
|
||||||
|
WholesaleSettings,
|
||||||
|
WholesaleDashboardStats,
|
||||||
|
WholesaleNotification,
|
||||||
|
WebhookSettings,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { resolveBrandId, enforceBrandScope } from "./scope";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleOrders,
|
||||||
|
getWholesalePickupOrders,
|
||||||
|
getWholesaleDashboardStats,
|
||||||
|
markWholesaleOrderFulfilled,
|
||||||
|
updateWholesaleOrderStatus,
|
||||||
|
deleteWholesaleOrder,
|
||||||
|
} from "./orders";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleCustomers,
|
||||||
|
saveWholesaleCustomer,
|
||||||
|
deleteWholesaleCustomer,
|
||||||
|
} from "./customers";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleProducts,
|
||||||
|
saveWholesaleProduct,
|
||||||
|
deleteWholesaleProduct,
|
||||||
|
} from "./products";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleSettings,
|
||||||
|
getWholesaleSettingsPublic,
|
||||||
|
saveWholesaleSettings,
|
||||||
|
} from "./settings";
|
||||||
|
|
||||||
|
export {
|
||||||
|
recordWholesaleDeposit,
|
||||||
|
bulkFulfillWholesaleOrders,
|
||||||
|
bulkRecordWholesaleDeposit,
|
||||||
|
} from "./deposits";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleNotificationStats,
|
||||||
|
getWholesalePendingNotifications,
|
||||||
|
markWholesaleNotificationSent,
|
||||||
|
enqueueWholesaleNotification,
|
||||||
|
} from "./notifications";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWebhookSettings,
|
||||||
|
saveWebhookSettings,
|
||||||
|
enqueueWholesaleWebhook,
|
||||||
|
getRecentWebhookActivity,
|
||||||
|
} from "./webhooks";
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import type { WholesaleNotification } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleNotificationStats(
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
||||||
|
"SELECT * FROM get_wholesale_notification_stats($1)",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||||
|
} catch {
|
||||||
|
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesalePendingNotifications(
|
||||||
|
brandId: string,
|
||||||
|
limit = 50
|
||||||
|
): Promise<WholesaleNotification[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
if (!adminUser.can_manage_orders) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleNotification>(
|
||||||
|
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
||||||
|
[brandId, limit]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markWholesaleNotificationSent(
|
||||||
|
notificationId: string,
|
||||||
|
error?: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT mark_wholesale_notification_sent($1, $2)",
|
||||||
|
[notificationId, error ?? null]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueWholesaleNotification(params: {
|
||||||
|
brandId: string;
|
||||||
|
customerId: string;
|
||||||
|
orderId: string;
|
||||||
|
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||||
|
emailTo: string;
|
||||||
|
emailCc?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
bodyText?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.customerId,
|
||||||
|
params.orderId,
|
||||||
|
params.type,
|
||||||
|
params.emailTo,
|
||||||
|
params.emailCc ?? null,
|
||||||
|
params.subject,
|
||||||
|
params.bodyHtml ?? null,
|
||||||
|
params.bodyText ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to enqueue notification" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||||
|
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleOrder>(
|
||||||
|
"SELECT * FROM get_wholesale_orders($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleOrder>(
|
||||||
|
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
||||||
|
const orders = await getWholesaleOrders(brandId);
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
||||||
|
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
||||||
|
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
||||||
|
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
||||||
|
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
||||||
|
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
||||||
|
|
||||||
|
return {
|
||||||
|
open_orders: open.length,
|
||||||
|
pickup_today: pickupToday.length,
|
||||||
|
past_due: pastDue.length,
|
||||||
|
total_unpaid: totalUnpaid,
|
||||||
|
awaiting_deposit: awaitingDep.length,
|
||||||
|
fulfilled_today: fulfilledToday.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
||||||
|
[orderId, adminUser.user_id]
|
||||||
|
);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return { success: false, error: "Failed to mark fulfilled" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to mark fulfilled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWholesaleOrderStatus(
|
||||||
|
orderId: string,
|
||||||
|
status: "pending" | "confirmed" | "cancelled",
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
||||||
|
[orderId, status]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to update order status" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT * FROM delete_wholesale_order($1)",
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to delete order" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import type { WholesaleProduct } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleProduct>(
|
||||||
|
"SELECT * FROM get_wholesale_products($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleProduct(params: {
|
||||||
|
brandId: string;
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
unitType?: string;
|
||||||
|
availability?: string;
|
||||||
|
qtyAvailable?: number;
|
||||||
|
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||||
|
hpSku?: string;
|
||||||
|
hpItemId?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
handlingInstructions?: string;
|
||||||
|
storageWarning?: string;
|
||||||
|
productLabel?: string;
|
||||||
|
packStyle?: string;
|
||||||
|
containerType?: string;
|
||||||
|
defaultPickupLocation?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.id ?? null,
|
||||||
|
params.name,
|
||||||
|
params.description ?? null,
|
||||||
|
params.unitType ?? "each",
|
||||||
|
params.availability ?? "unavailable",
|
||||||
|
params.qtyAvailable ?? 0,
|
||||||
|
JSON.stringify(params.priceTiers ?? []),
|
||||||
|
params.hpSku ?? null,
|
||||||
|
params.hpItemId ?? null,
|
||||||
|
params.internalNotes ?? null,
|
||||||
|
params.handlingInstructions ?? null,
|
||||||
|
params.storageWarning ?? null,
|
||||||
|
params.productLabel ?? null,
|
||||||
|
params.packStyle ?? null,
|
||||||
|
params.containerType ?? null,
|
||||||
|
params.defaultPickupLocation ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, id: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save product" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM delete_wholesale_product($1)",
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Brand scoping helpers shared across the wholesale actions.
|
||||||
|
*
|
||||||
|
* No "use server" so this can also be imported from non-action contexts
|
||||||
|
* (e.g. middleware, tests).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
||||||
|
*
|
||||||
|
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||||
|
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||||
|
* store_employee → their own brand_id
|
||||||
|
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||||
|
* unauthenticated → null (actions should already bail out earlier)
|
||||||
|
*
|
||||||
|
* This prevents brand_admin from seeing or modifying another brand's data
|
||||||
|
* even if they manually pass a different brandId to the action.
|
||||||
|
*/
|
||||||
|
export async function resolveBrandId(
|
||||||
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
|
requestedBrandId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
|
// Brand admin trying to operate on another brand's data — block it
|
||||||
|
return null; // caller should check and return unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeBrandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
||||||
|
* if a brand_admin tries to operate outside their brand.
|
||||||
|
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||||
|
*/
|
||||||
|
export async function enforceBrandScope(
|
||||||
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
|
requestedBrandId?: string
|
||||||
|
): Promise<{ brandId: string | null; error?: string }> {
|
||||||
|
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
return { brandId: null }; // unrestricted
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
|
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { brandId: activeBrandId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import type { NotificationRecipient, WholesaleSettings } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
const bid = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!bid && adminUser.role !== "platform_admin") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleSettings>(
|
||||||
|
"SELECT * FROM get_wholesale_settings($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
||||||
|
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleSettings(params: {
|
||||||
|
brandId: string;
|
||||||
|
requireApproval?: boolean;
|
||||||
|
minOrderAmount?: number;
|
||||||
|
onlinePaymentEnabled?: boolean;
|
||||||
|
wholesaleEnabled?: boolean;
|
||||||
|
squareSyncEnabled?: boolean;
|
||||||
|
pickupLocation?: string;
|
||||||
|
fobLocation?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
invoiceBusinessName?: string;
|
||||||
|
invoiceBusinessAddress?: string;
|
||||||
|
invoiceBusinessPhone?: string;
|
||||||
|
invoiceBusinessEmail?: string;
|
||||||
|
invoiceBusinessWebsite?: string;
|
||||||
|
notificationEmail?: string;
|
||||||
|
notificationRecipients?: NotificationRecipient[];
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.requireApproval ?? null,
|
||||||
|
params.minOrderAmount ?? null,
|
||||||
|
params.onlinePaymentEnabled ?? null,
|
||||||
|
params.wholesaleEnabled ?? null,
|
||||||
|
params.pickupLocation ?? null,
|
||||||
|
params.fobLocation ?? null,
|
||||||
|
params.fromEmail ?? null,
|
||||||
|
params.invoiceBusinessName ?? null,
|
||||||
|
params.invoiceBusinessAddress ?? null,
|
||||||
|
params.invoiceBusinessPhone ?? null,
|
||||||
|
params.invoiceBusinessEmail ?? null,
|
||||||
|
params.invoiceBusinessWebsite ?? null,
|
||||||
|
params.notificationEmail ?? null,
|
||||||
|
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
||||||
|
params.squareSyncEnabled ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save settings" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for wholesale actions.
|
||||||
|
*
|
||||||
|
* Pure type definitions — no runtime imports, no "use server" so it can
|
||||||
|
* be imported into both server and client code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WholesaleOrder = {
|
||||||
|
id: string;
|
||||||
|
customer_id: string;
|
||||||
|
status: string;
|
||||||
|
fulfillment_status: string;
|
||||||
|
payment_status: string;
|
||||||
|
anticipated_pickup_date: string | null;
|
||||||
|
subtotal: number;
|
||||||
|
deposit_required: number;
|
||||||
|
deposit_paid: number;
|
||||||
|
balance_due: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
invoice_number: string | null;
|
||||||
|
assigned_employee_id: string | null;
|
||||||
|
company_name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
customer_email: string;
|
||||||
|
customer_phone: string | null;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
product_name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
line_total: number;
|
||||||
|
}>;
|
||||||
|
fulfilled_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleCustomer = {
|
||||||
|
id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
company_name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
account_status: string;
|
||||||
|
credit_limit: number;
|
||||||
|
deposits_enabled: boolean;
|
||||||
|
deposit_threshold: number | null;
|
||||||
|
deposit_percentage: number | null;
|
||||||
|
order_email: string | null;
|
||||||
|
invoice_email: string | null;
|
||||||
|
admin_notes: string | null;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleProduct = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
unit_type: string;
|
||||||
|
unit_type_custom: string | null;
|
||||||
|
availability: string;
|
||||||
|
qty_available: number;
|
||||||
|
season_start: string | null;
|
||||||
|
season_end: string | null;
|
||||||
|
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||||
|
hp_sku: string | null;
|
||||||
|
hp_item_id: string | null;
|
||||||
|
handling_instructions: string | null;
|
||||||
|
storage_warning: string | null;
|
||||||
|
loading_notes: string | null;
|
||||||
|
product_label: string | null;
|
||||||
|
pack_style: string | null;
|
||||||
|
container_type: string | null;
|
||||||
|
container_size_code: string | null;
|
||||||
|
units_per_container: number | null;
|
||||||
|
default_pickup_location: string | null;
|
||||||
|
created_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationRecipient = {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
active: boolean;
|
||||||
|
notification_types?: (
|
||||||
|
| "order_confirmation"
|
||||||
|
| "deposit_received"
|
||||||
|
| "order_fulfilled"
|
||||||
|
| "price_sheet"
|
||||||
|
| "unclaimed_pickup"
|
||||||
|
)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleSettings = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
portal_page_id: string | null;
|
||||||
|
price_sheet_page_id: string | null;
|
||||||
|
require_approval: boolean;
|
||||||
|
min_order_amount: number | null;
|
||||||
|
online_payment_enabled: boolean;
|
||||||
|
wholesale_enabled: boolean;
|
||||||
|
square_sync_enabled: boolean;
|
||||||
|
pickup_location: string;
|
||||||
|
fob_location: string;
|
||||||
|
from_email: string;
|
||||||
|
invoice_business_name: string;
|
||||||
|
invoice_business_address: string | null;
|
||||||
|
invoice_business_phone: string | null;
|
||||||
|
invoice_business_email: string | null;
|
||||||
|
invoice_business_website: string | null;
|
||||||
|
notification_email: string | null;
|
||||||
|
notification_recipients: NotificationRecipient[];
|
||||||
|
last_invoice_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleDashboardStats = {
|
||||||
|
open_orders: number;
|
||||||
|
pickup_today: number;
|
||||||
|
past_due: number;
|
||||||
|
total_unpaid: number;
|
||||||
|
awaiting_deposit: number;
|
||||||
|
fulfilled_today: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleNotification = {
|
||||||
|
id: string;
|
||||||
|
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||||
|
email_to: string;
|
||||||
|
email_cc: string | null;
|
||||||
|
subject: string;
|
||||||
|
body_html: string | null;
|
||||||
|
body_text: string | null;
|
||||||
|
brand_id: string;
|
||||||
|
customer_id: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
invoice_business_name: string | null;
|
||||||
|
invoice_business_email: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookSettings = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import type { WebhookSettings } from "./types";
|
||||||
|
|
||||||
|
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WebhookSettings>(
|
||||||
|
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWebhookSettings(params: {
|
||||||
|
brandId: string;
|
||||||
|
url?: string;
|
||||||
|
secret?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
brand_id: params.brandId,
|
||||||
|
url: params.url ?? "",
|
||||||
|
secret: params.secret ?? "",
|
||||||
|
enabled: params.enabled ?? false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save webhook settings" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueWholesaleWebhook(
|
||||||
|
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
||||||
|
orderId: string | null = null,
|
||||||
|
payload: Record<string, unknown> | null = null,
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; logId?: string }> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
||||||
|
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, logId: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
response: string | null;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
response: string | null;
|
||||||
|
}>(
|
||||||
|
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
|
[brandId, limit]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user