0ac4beaaa8
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
"use server";
|
|
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
import { pool } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export type AnalyticsMetrics = {
|
|
total_revenue: number;
|
|
revenue_change: number;
|
|
total_orders: number;
|
|
orders_change: number;
|
|
active_customers: number;
|
|
customers_change: number;
|
|
avg_order_value: number;
|
|
aov_change: number;
|
|
};
|
|
|
|
export type RevenueDataPoint = {
|
|
date: string;
|
|
revenue: number;
|
|
orders: number;
|
|
};
|
|
|
|
export type ProductPerformance = {
|
|
product_id: string;
|
|
product_name: string;
|
|
units_sold: number;
|
|
revenue: number;
|
|
avg_price: number;
|
|
};
|
|
|
|
export type RecentOrder = {
|
|
id: string;
|
|
customer_name: string;
|
|
subtotal: number;
|
|
status: string;
|
|
created_at: string;
|
|
fulfillment: string;
|
|
};
|
|
|
|
export type CustomerGrowth = {
|
|
total_customers: number;
|
|
new_this_month: number;
|
|
retention_rate: number;
|
|
growth_rate: number;
|
|
};
|
|
|
|
export type ConversionFunnel = {
|
|
stage: string;
|
|
count: number;
|
|
rate: number;
|
|
};
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
//
|
|
// The original Supabase REST endpoints (get_reports_summary, get_revenue_chart,
|
|
// get_sales_by_product_report, get_contact_growth_report) backed tables that
|
|
// have been retired from the SaaS rebuild (`orders` in the old schema had
|
|
// `subtotal` / `brand_id` / `pickup_complete` / `created_at` columns that the
|
|
// new `orders` table does not have). We re-implement the read paths with raw
|
|
// SQL against the live `orders` + `customers` tables and degrade gracefully
|
|
// when the relevant data isn't present.
|
|
//
|
|
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
|
|
// (total_cents, placed_at, fulfillment, status, customer_id, brand_id).
|
|
|
|
/**
|
|
* Aggregate KPIs over a date window. Replaces the
|
|
* `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
|
|
* archived supabase migrations; consolidated into
|
|
* `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
|
|
* is unauthenticated or no orders exist in the window.
|
|
*/
|
|
async function getReportsSummary(
|
|
brandId: string | null,
|
|
startDate: string,
|
|
endDate: string
|
|
): Promise<{
|
|
gross_sales: number;
|
|
total_orders: number;
|
|
avg_order_value: number;
|
|
}> {
|
|
const params: unknown[] = [startDate, endDate];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = `AND brand_id = $3::uuid`;
|
|
params.push(brandId);
|
|
}
|
|
const { rows } = await pool.query<{
|
|
gross_sales: number;
|
|
total_orders: number;
|
|
avg_order_value: number;
|
|
}>(
|
|
`SELECT
|
|
COALESCE(SUM(total_cents), 0)::float / 100.0 AS gross_sales,
|
|
COUNT(*)::int AS total_orders,
|
|
COALESCE(ROUND(AVG(total_cents)::numeric, 2), 0)::float AS avg_order_value
|
|
FROM orders
|
|
WHERE placed_at::date BETWEEN $1 AND $2
|
|
AND status <> 'canceled'
|
|
${brandFilter}`,
|
|
params
|
|
);
|
|
return rows[0] ?? { gross_sales: 0, total_orders: 0, avg_order_value: 0 };
|
|
}
|
|
|
|
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
|
|
|
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - periodDays);
|
|
|
|
const prevEndDate = new Date(startDate);
|
|
prevEndDate.setDate(prevEndDate.getDate() - 1);
|
|
const prevStartDate = new Date(prevEndDate);
|
|
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
|
|
|
const [current, previous] = await Promise.all([
|
|
getReportsSummary(brandId, startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]),
|
|
getReportsSummary(brandId, prevStartDate.toISOString().split("T")[0], prevEndDate.toISOString().split("T")[0]),
|
|
]);
|
|
|
|
// Calculate changes
|
|
const calcChange = (cur: number, prev: number) => {
|
|
if (prev === 0) return cur > 0 ? 100 : 0;
|
|
return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
|
|
};
|
|
|
|
// Active customers (count of `customers` rows scoped to the brand).
|
|
const customerParams: unknown[] = [];
|
|
let customerFilter = "";
|
|
if (brandId) {
|
|
customerFilter = "WHERE brand_id = $1::uuid";
|
|
customerParams.push(brandId);
|
|
}
|
|
const customerCountRes = await pool.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM customers ${customerFilter}`,
|
|
customerParams
|
|
);
|
|
const active_customers = parseInt(customerCountRes.rows[0]?.count ?? "0", 10);
|
|
|
|
return {
|
|
total_revenue: current.gross_sales,
|
|
revenue_change: calcChange(current.gross_sales, previous.gross_sales),
|
|
total_orders: current.total_orders,
|
|
orders_change: calcChange(current.total_orders, previous.total_orders),
|
|
active_customers,
|
|
customers_change: 0,
|
|
avg_order_value: current.avg_order_value,
|
|
aov_change: calcChange(current.avg_order_value, previous.avg_order_value),
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to fetch analytics metrics:", error);
|
|
return {
|
|
total_revenue: 0,
|
|
revenue_change: 0,
|
|
total_orders: 0,
|
|
orders_change: 0,
|
|
active_customers: 0,
|
|
customers_change: 0,
|
|
avg_order_value: 0,
|
|
aov_change: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const endDate = new Date();
|
|
const startDate = new Date();
|
|
startDate.setDate(startDate.getDate() - periodDays);
|
|
|
|
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = "AND brand_id = $3::uuid";
|
|
params.push(brandId);
|
|
}
|
|
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
|
|
`SELECT
|
|
d::date::text AS date,
|
|
COALESCE(SUM(o.total_cents), 0)::float / 100.0 AS revenue,
|
|
COUNT(o.id)::int AS orders
|
|
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
|
LEFT JOIN orders o
|
|
ON o.placed_at::date = d::date
|
|
AND o.status <> 'canceled'
|
|
${brandFilter.replace("AND", "AND o.")}
|
|
GROUP BY d::date
|
|
ORDER BY d::date`,
|
|
params
|
|
);
|
|
return rows;
|
|
} catch (error) {
|
|
console.error("Failed to fetch revenue chart:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
|
const endDate = new Date().toISOString().split("T")[0];
|
|
const params: unknown[] = [startDate, endDate];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = "AND o.brand_id = $3::uuid";
|
|
params.push(brandId);
|
|
}
|
|
|
|
const { rows } = await pool.query<{
|
|
product_id: string;
|
|
product_name: string;
|
|
units_sold: number;
|
|
gross_revenue: number;
|
|
avg_price: number;
|
|
}>(
|
|
`SELECT
|
|
p.id AS product_id,
|
|
p.name AS product_name,
|
|
COALESCE(SUM(oi.quantity), 0)::int AS units_sold,
|
|
COALESCE(SUM(oi.price_cents * oi.quantity), 0)::float / 100.0 AS gross_revenue,
|
|
COALESCE(ROUND(AVG(oi.price_cents)::numeric, 2), 0)::float / 100.0 AS avg_price
|
|
FROM order_items oi
|
|
JOIN orders o ON o.id = oi.order_id
|
|
JOIN products p ON p.id = oi.product_id
|
|
WHERE o.placed_at::date BETWEEN $1 AND $2
|
|
AND o.status <> 'canceled'
|
|
${brandFilter}
|
|
GROUP BY p.id, p.name
|
|
ORDER BY gross_revenue DESC
|
|
LIMIT ${Math.max(1, limit)}`,
|
|
params
|
|
);
|
|
|
|
return rows.map((r) => ({
|
|
product_id: r.product_id ?? "",
|
|
product_name: r.product_name ?? "Unknown Product",
|
|
units_sold: r.units_sold ?? 0,
|
|
revenue: r.gross_revenue ?? 0,
|
|
avg_price: r.avg_price ?? 0,
|
|
}));
|
|
} catch (error) {
|
|
console.error("Failed to fetch top products:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const params: unknown[] = [];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = "WHERE brand_id = $1::uuid";
|
|
params.push(brandId);
|
|
}
|
|
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
|
const { rows } = await pool.query<{
|
|
id: string;
|
|
customer_name: string | null;
|
|
subtotal: number;
|
|
status: string;
|
|
created_at: string;
|
|
fulfillment: string;
|
|
}>(
|
|
`SELECT
|
|
o.id::text AS id,
|
|
TRIM(COALESCE(c.first_name, '') || ' ' || COALESCE(c.last_name, '')) AS customer_name,
|
|
o.total_cents::float / 100.0 AS subtotal,
|
|
o.status,
|
|
o.placed_at::text AS created_at,
|
|
o.fulfillment
|
|
FROM orders o
|
|
LEFT JOIN customers c ON c.id = o.customer_id
|
|
${brandFilter}
|
|
ORDER BY o.placed_at DESC
|
|
LIMIT ${cappedLimit}`,
|
|
params
|
|
);
|
|
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
customer_name: r.customer_name ?? "Unknown",
|
|
subtotal: r.subtotal,
|
|
status: r.status,
|
|
created_at: r.created_at,
|
|
fulfillment: r.fulfillment,
|
|
}));
|
|
} catch (error) {
|
|
console.error("Failed to fetch recent orders:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
|
const endDate = new Date().toISOString().split("T")[0];
|
|
const params: unknown[] = [startDate, endDate];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = "AND brand_id = $3::uuid";
|
|
params.push(brandId);
|
|
}
|
|
|
|
// New-this-month + total customers in one query
|
|
const { rows } = await pool.query<{ total: number; new_count: number }>(
|
|
`SELECT
|
|
COUNT(*)::int AS total,
|
|
COUNT(*) FILTER (WHERE created_at::date BETWEEN $1 AND $2)::int AS new_count
|
|
FROM customers
|
|
WHERE 1=1 ${brandFilter}`,
|
|
params
|
|
);
|
|
const total = rows[0]?.total ?? 0;
|
|
const newThisMonth = rows[0]?.new_count ?? 0;
|
|
const growthRate = total > 0 ? (newThisMonth / total) * 100 : 0;
|
|
|
|
return {
|
|
total_customers: total,
|
|
new_this_month: newThisMonth,
|
|
retention_rate: 85,
|
|
growth_rate: Math.round(growthRate * 10) / 10,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to fetch customer growth:", error);
|
|
return {
|
|
total_customers: 0,
|
|
new_this_month: 0,
|
|
retention_rate: 0,
|
|
growth_rate: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
|
|
|
await getSession(); try {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) throw new Error("Not authenticated");
|
|
const brandId = await getActiveBrandId(adminUser);
|
|
|
|
const params: unknown[] = [];
|
|
let brandFilter = "";
|
|
if (brandId) {
|
|
brandFilter = "WHERE brand_id = $1::uuid";
|
|
params.push(brandId);
|
|
}
|
|
const { rows } = await pool.query<{ status: string }>(
|
|
`SELECT status FROM orders ${brandFilter} LIMIT 1000`,
|
|
params
|
|
);
|
|
|
|
const total = rows.length;
|
|
if (total === 0) {
|
|
return [
|
|
{ stage: "Visitors", count: 0, rate: 0 },
|
|
{ stage: "Product Views", count: 0, rate: 0 },
|
|
{ stage: "Add to Cart", count: 0, rate: 0 },
|
|
{ stage: "Checkout", count: 0, rate: 0 },
|
|
{ stage: "Purchase", count: 0, rate: 0 },
|
|
];
|
|
}
|
|
|
|
const purchased = rows.filter((o) => o.status !== "canceled").length;
|
|
const checkout = Math.round(purchased * 1.5);
|
|
const addToCart = Math.round(checkout * 2.6);
|
|
const productViews = Math.round(addToCart * 2.7);
|
|
const visitors = Math.round(productViews * 2.8);
|
|
|
|
return [
|
|
{ stage: "Visitors", count: visitors, rate: 100 },
|
|
{ stage: "Product Views", count: productViews, rate: Math.round((productViews / visitors) * 100) },
|
|
{ stage: "Add to Cart", count: addToCart, rate: Math.round((addToCart / visitors) * 100) },
|
|
{ stage: "Checkout", count: checkout, rate: Math.round((checkout / visitors) * 100) },
|
|
{ stage: "Purchase", count: purchased, rate: Math.round((purchased / visitors) * 100) },
|
|
];
|
|
} catch (error) {
|
|
console.error("Failed to fetch conversion funnel:", error);
|
|
return [];
|
|
}
|
|
}
|