migrate: replace Supabase REST with Drizzle/pg in 11 more action files (wave 5 partial)
- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct, getContactGrowth, getRecentOrders, getConversionFunnel against pool + new orders/customers schema. Drops retired columns (subtotal, pickup_complete) and re-implements the SQL by hand. - import-orders.ts: bulk import via withTx using orders + orderItems + customers Drizzle tables, computes total_cents from current product prices. - import-products.ts: rewrite to use withTenant(brandId) and Drizzle products table. - products/create-product.ts, update-product.ts, upload-image.ts: switch to withTenant + Drizzle; image_url moves to product_images table. - reports.ts: rewrite against pool + new orders schema. - route-trace/lots.ts: stub functions (route-trace feature retired from SaaS rebuild — harvest_lots table not in db/schema). Uses discriminated union return types so consumer narrowing works in both branches. - settings/features.ts: switch to withTenant + Drizzle brandSettings. - shipping.ts: switch to pool + Drizzle orders/orderItems. - api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous'). Typecheck: clean. Tests: 22/22 pass. Build: succeeds.
This commit is contained in:
+214
-148
@@ -2,10 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,67 +53,66 @@ export type ConversionFunnel = {
|
||||
rate: number;
|
||||
};
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
// ── 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, tenant_id).
|
||||
|
||||
async function brandScopedFetch<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit & { params?: Record<string, string> }
|
||||
): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
// brandId is available for future use in filtering
|
||||
void adminUser.brand_id;
|
||||
|
||||
let url = `${supabaseUrl}/rest/v1${endpoint}`;
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams(options.params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
/**
|
||||
* Aggregate KPIs over a date window. Replaces the
|
||||
* `get_reports_summary` SECURITY DEFINER RPC from
|
||||
* `supabase/migrations/031_reports_v1_rpcs.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 tenant_id = $3::uuid`;
|
||||
params.push(brandId);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Analytics fetch failed: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function brandScopedRPC<T>(
|
||||
rpcName: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, ...params }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
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> {
|
||||
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);
|
||||
@@ -126,51 +122,42 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
||||
const prevStartDate = new Date(prevEndDate);
|
||||
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
||||
|
||||
// Current period
|
||||
const current = await brandScopedRPC<{
|
||||
gross_sales: number;
|
||||
total_orders: number;
|
||||
avg_order_value: number;
|
||||
}>("get_reports_summary", {
|
||||
p_start_date: startDate.toISOString().split("T")[0],
|
||||
p_end_date: endDate.toISOString().split("T")[0],
|
||||
});
|
||||
|
||||
// Previous period
|
||||
const previous = await brandScopedRPC<{
|
||||
gross_sales: number;
|
||||
total_orders: number;
|
||||
avg_order_value: number;
|
||||
}>("get_reports_summary", {
|
||||
p_start_date: prevStartDate.toISOString().split("T")[0],
|
||||
p_end_date: prevEndDate.toISOString().split("T")[0],
|
||||
});
|
||||
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 = (current: number, previous: number) => {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100 * 10) / 10;
|
||||
const calcChange = (cur: number, prev: number) => {
|
||||
if (prev === 0) return cur > 0 ? 100 : 0;
|
||||
return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
|
||||
};
|
||||
|
||||
// Get customer count
|
||||
const customers = await brandScopedFetch<{ count: number }[]>(
|
||||
"/communication_contacts",
|
||||
{ params: { select: "count", limit: "1" } }
|
||||
// Active customers (count of `customers` rows scoped to the brand).
|
||||
const customerParams: unknown[] = [];
|
||||
let customerFilter = "";
|
||||
if (brandId) {
|
||||
customerFilter = "WHERE tenant_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 ?? 0,
|
||||
revenue_change: calcChange(current?.gross_sales ?? 0, previous?.gross_sales ?? 0),
|
||||
total_orders: current?.total_orders ?? 0,
|
||||
orders_change: calcChange(current?.total_orders ?? 0, previous?.total_orders ?? 0),
|
||||
active_customers: Array.isArray(customers) ? customers[0]?.count ?? 0 : 0,
|
||||
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 ?? 0,
|
||||
aov_change: calcChange(current?.avg_order_value ?? 0, previous?.avg_order_value ?? 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 zeros on error
|
||||
return {
|
||||
total_revenue: 0,
|
||||
revenue_change: 0,
|
||||
@@ -186,16 +173,35 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
||||
|
||||
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
||||
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 data = await brandScopedRPC<RevenueDataPoint[]>("get_revenue_chart", {
|
||||
p_start_date: startDate.toISOString().split("T")[0],
|
||||
p_end_date: endDate.toISOString().split("T")[0],
|
||||
});
|
||||
|
||||
return data ?? [];
|
||||
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
brandFilter = "AND tenant_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 [];
|
||||
@@ -204,23 +210,50 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
||||
|
||||
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
||||
try {
|
||||
const result = await brandScopedRPC<Array<{
|
||||
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.tenant_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;
|
||||
product_id: string;
|
||||
}>>("get_sales_by_product_report", {
|
||||
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||
p_end_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
}>(
|
||||
`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 (result ?? []).slice(0, limit).map(item => ({
|
||||
product_id: item.product_id ?? "",
|
||||
product_name: item.product_name ?? "Unknown Product",
|
||||
units_sold: item.units_sold ?? 0,
|
||||
revenue: item.gross_revenue ?? 0,
|
||||
avg_price: item.avg_price ?? 0,
|
||||
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);
|
||||
@@ -232,22 +265,46 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
||||
order: "created_at.desc",
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
const params: unknown[] = [];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
params.append("brand_id", "eq." + brandId);
|
||||
brandFilter = "WHERE tenant_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,
|
||||
c.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
|
||||
);
|
||||
|
||||
const data = await brandScopedFetch<RecentOrder[]>(`/orders?${params.toString()}`);
|
||||
|
||||
return data ?? [];
|
||||
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 [];
|
||||
@@ -256,26 +313,37 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
||||
|
||||
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||
try {
|
||||
const result = await brandScopedRPC<{
|
||||
total: number;
|
||||
new_contacts: number;
|
||||
growth_rate: number;
|
||||
}>("get_contact_growth_report", {
|
||||
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||
p_end_date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Get total customers
|
||||
const totalCustomers = await brandScopedFetch<{ count: number }[]>(
|
||||
"/communication_contacts",
|
||||
{ params: { select: "count", limit: "1" } }
|
||||
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 tenant_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: Array.isArray(totalCustomers) ? totalCustomers[0]?.count ?? 0 : 0,
|
||||
new_this_month: result?.new_contacts ?? 0,
|
||||
total_customers: total,
|
||||
new_this_month: newThisMonth,
|
||||
retention_rate: 85,
|
||||
growth_rate: result?.growth_rate ?? 0,
|
||||
growth_rate: Math.round(growthRate * 10) / 10,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch customer growth:", error);
|
||||
@@ -289,25 +357,23 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||
}
|
||||
|
||||
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
// Return a standardized funnel based on order data
|
||||
try {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
const brandId = await getActiveBrandId(adminUser);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
select: "id,status",
|
||||
limit: "1000",
|
||||
});
|
||||
|
||||
const params: unknown[] = [];
|
||||
let brandFilter = "";
|
||||
if (brandId) {
|
||||
params.append("brand_id", "eq." + brandId);
|
||||
brandFilter = "WHERE tenant_id = $1::uuid";
|
||||
params.push(brandId);
|
||||
}
|
||||
const { rows } = await pool.query<{ status: string }>(
|
||||
`SELECT status FROM orders ${brandFilter} LIMIT 1000`,
|
||||
params
|
||||
);
|
||||
|
||||
const orders = await brandScopedFetch<Array<{ status: string }>>(`/orders?${params.toString()}`);
|
||||
|
||||
const total = orders?.length ?? 0;
|
||||
const total = rows.length;
|
||||
if (total === 0) {
|
||||
return [
|
||||
{ stage: "Visitors", count: 0, rate: 0 },
|
||||
@@ -318,7 +384,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
];
|
||||
}
|
||||
|
||||
const purchased = orders?.filter(o => o.status !== "cancelled").length ?? 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);
|
||||
@@ -335,4 +401,4 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||
console.error("Failed to fetch conversion funnel:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user