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:
2026-06-07 05:26:03 +00:00
parent 3f323dd52a
commit 67abcaa2db
11 changed files with 834 additions and 762 deletions
+214 -148
View File
@@ -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 [];
}
}
}