Files
route-commerce/src/actions/analytics.ts
T
Nora 0ac4beaaa8 fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- 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)
2026-06-25 23:49:37 -06:00

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 [];
}
}