migrate: replace Supabase REST with Drizzle/pg in core admin (wave 1)
This commit is contained in:
+144
-155
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,35 +28,6 @@ export type DashboardSummary = {
|
||||
active_products: number;
|
||||
};
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function brandScopedFetch<T>(
|
||||
endpoint: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) throw new Error("Not authenticated");
|
||||
|
||||
let url = `${supabaseUrl}/rest/v1${endpoint}`;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Dashboard fetch failed: ${err}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
|
||||
|
||||
export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
@@ -74,58 +42,69 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// Get start of week (7 days ago) - kept for potential weekly comparison
|
||||
const _weekStart = new Date(startOfDay);
|
||||
_weekStart.setDate(_weekStart.getDate() - 6);
|
||||
|
||||
// Fetch today's orders
|
||||
let todayOrdersQuery = `${supabaseUrl}/rest/v1/orders?select=id,subtotal,status`;
|
||||
todayOrdersQuery += `&created_at=gte.${startOfDay.toISOString()}`;
|
||||
todayOrdersQuery += `&created_at=lt.${endOfDay.toISOString()}`;
|
||||
if (brandId) {
|
||||
todayOrdersQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
|
||||
const todayOrdersRes = await fetch(todayOrdersQuery, {
|
||||
headers: svcHeaders(supabaseKey),
|
||||
});
|
||||
const todayOrders = await todayOrdersRes.json();
|
||||
// Fetch today's orders. `orders` and `stops` use the legacy schema
|
||||
// (column names like `subtotal`, `brand_id`, `date`); the new-schema
|
||||
// Drizzle `orders` table doesn't have these. Raw SQL via the shared
|
||||
// pg pool.
|
||||
const todayOrdersRes = brandId
|
||||
? await pool.query<{ subtotal: number; status: string }>(
|
||||
`SELECT subtotal, status
|
||||
FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
AND brand_id = $3`,
|
||||
[startOfDay.toISOString(), endOfDay.toISOString(), brandId],
|
||||
)
|
||||
: await pool.query<{ subtotal: number; status: string }>(
|
||||
`SELECT subtotal, status
|
||||
FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2`,
|
||||
[startOfDay.toISOString(), endOfDay.toISOString()],
|
||||
);
|
||||
const todayOrders = todayOrdersRes.rows;
|
||||
|
||||
// Calculate today's revenue and orders
|
||||
const validOrders = (todayOrders as Array<{subtotal: number; status: string}>)
|
||||
.filter(o => o.status !== "cancelled");
|
||||
const todayRevenue = validOrders
|
||||
.reduce((sum, o) => sum + (o.subtotal || 0), 0);
|
||||
const validOrders = todayOrders.filter((o) => o.status !== "cancelled");
|
||||
const todayRevenue = validOrders.reduce(
|
||||
(sum, o) => sum + (o.subtotal || 0),
|
||||
0,
|
||||
);
|
||||
const todayOrderCount = validOrders.length;
|
||||
|
||||
// Fetch pending stops (stops where date >= today and status is scheduled/upcoming)
|
||||
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`;
|
||||
stopsQuery += `&date=gte.${startOfDay.toISOString().split("T")[0]}`;
|
||||
stopsQuery += `&status=eq.scheduled`;
|
||||
if (brandId) {
|
||||
stopsQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
stopsQuery += `&limit=100`;
|
||||
|
||||
const stopsRes = await fetch(stopsQuery, {
|
||||
headers: svcHeaders(supabaseKey),
|
||||
});
|
||||
const pendingStopsData = await stopsRes.json();
|
||||
const pendingStops = Array.isArray(pendingStopsData) ? pendingStopsData.length : 0;
|
||||
// Fetch pending stops (stops where date >= today and status is scheduled)
|
||||
const stopsRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM stops
|
||||
WHERE date >= $1
|
||||
AND status = 'scheduled'
|
||||
AND brand_id = $2
|
||||
LIMIT 100`,
|
||||
[startOfDay.toISOString().split("T")[0], brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM stops
|
||||
WHERE date >= $1
|
||||
AND status = 'scheduled'
|
||||
LIMIT 100`,
|
||||
[startOfDay.toISOString().split("T")[0]],
|
||||
);
|
||||
const pendingStops = stopsRes.rows.length;
|
||||
|
||||
// Fetch active products
|
||||
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id`;
|
||||
productsQuery += `&active=eq.true`;
|
||||
if (brandId) {
|
||||
productsQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
productsQuery += `&limit=1000`;
|
||||
|
||||
const productsRes = await fetch(productsQuery, {
|
||||
headers: svcHeaders(supabaseKey),
|
||||
});
|
||||
const productsData = await productsRes.json();
|
||||
const activeProducts = Array.isArray(productsData) ? productsData.length : 0;
|
||||
const productsRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM products
|
||||
WHERE active = true AND brand_id = $1
|
||||
LIMIT 1000`,
|
||||
[brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM products
|
||||
WHERE active = true
|
||||
LIMIT 1000`,
|
||||
);
|
||||
const activeProducts = productsRes.rows.length;
|
||||
|
||||
// Fetch weekly orders for chart (last 7 days)
|
||||
const weeklyOrders: number[] = [];
|
||||
@@ -134,41 +113,57 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
dayStart.setDate(dayStart.getDate() - i);
|
||||
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
let dayQuery = `${supabaseUrl}/rest/v1/orders?select=id,status`;
|
||||
dayQuery += `&created_at=gte.${dayStart.toISOString()}`;
|
||||
dayQuery += `&created_at=lt.${dayEnd.toISOString()}`;
|
||||
if (brandId) {
|
||||
dayQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
dayQuery += `&limit=1`;
|
||||
|
||||
const dayRes = await fetch(dayQuery, {
|
||||
headers: svcHeaders(supabaseKey),
|
||||
});
|
||||
// Use X-Total-Count header or count from response
|
||||
const count = dayRes.headers.get("X-Total-Count");
|
||||
weeklyOrders.push(count ? parseInt(count) : 0);
|
||||
const dayRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
AND brand_id = $3
|
||||
LIMIT 1`,
|
||||
[dayStart.toISOString(), dayEnd.toISOString(), brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM orders
|
||||
WHERE created_at >= $1
|
||||
AND created_at < $2
|
||||
LIMIT 1`,
|
||||
[dayStart.toISOString(), dayEnd.toISOString()],
|
||||
);
|
||||
weeklyOrders.push(dayRes.rows.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Fetch recent orders (last 10)
|
||||
let recentQuery = `${supabaseUrl}/rest/v1/orders?select=id,customer_name,subtotal,status,created_at`;
|
||||
recentQuery += `&order=created_at.desc`;
|
||||
recentQuery += `&limit=10`;
|
||||
if (brandId) {
|
||||
recentQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
const recentRes = brandId
|
||||
? await pool.query<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
subtotal: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, customer_name, subtotal, status, created_at
|
||||
FROM orders
|
||||
WHERE brand_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10`,
|
||||
[brandId],
|
||||
)
|
||||
: await pool.query<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
subtotal: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, customer_name, subtotal, status, created_at
|
||||
FROM orders
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10`,
|
||||
);
|
||||
|
||||
const recentRes = await fetch(recentQuery, {
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
Prefer: "count=exact",
|
||||
},
|
||||
});
|
||||
const recentOrdersData = await recentRes.json();
|
||||
|
||||
const recentOrders = (Array.isArray(recentOrdersData) ? recentOrdersData : [])
|
||||
.filter((o: {status: string}) => o.status !== "cancelled")
|
||||
.map((o: {id: string; customer_name: string; subtotal: number; status: string; created_at: string}) => ({
|
||||
const recentOrders = recentRes.rows
|
||||
.filter((o) => o.status !== "cancelled")
|
||||
.map((o) => ({
|
||||
id: o.id || "",
|
||||
customer_name: o.customer_name || "Guest",
|
||||
total: o.subtotal || 0,
|
||||
@@ -186,7 +181,6 @@ export async function getDashboardStats(): Promise<DashboardStats> {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard stats:", error);
|
||||
// Return zeros on error
|
||||
return {
|
||||
todayOrders: 0,
|
||||
todayRevenue: 0,
|
||||
@@ -207,58 +201,53 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// Get gross sales from reports RPC
|
||||
const rpcRes = await fetch(`${supabaseUrl}/rest/v1/rpc/get_reports_summary`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_start_date: thirtyDaysAgo.toISOString().split("T")[0],
|
||||
p_end_date: new Date().toISOString().split("T")[0],
|
||||
}),
|
||||
});
|
||||
|
||||
// `get_reports_summary` is a SECURITY DEFINER RPC that returns the
|
||||
// gross sales + total order counts. Migration 031.
|
||||
let total_revenue = 0;
|
||||
let total_orders = 0;
|
||||
|
||||
if (rpcRes.ok) {
|
||||
const data = await rpcRes.json();
|
||||
try {
|
||||
const { rows } = await pool.query<{
|
||||
gross_sales: number;
|
||||
total_orders: number;
|
||||
}>(
|
||||
"SELECT * FROM get_reports_summary($1, $2, $3)",
|
||||
[
|
||||
brandId,
|
||||
thirtyDaysAgo.toISOString().split("T")[0],
|
||||
new Date().toISOString().split("T")[0],
|
||||
],
|
||||
);
|
||||
const data = rows[0];
|
||||
total_revenue = data?.gross_sales ?? 0;
|
||||
total_orders = data?.total_orders ?? 0;
|
||||
} catch {
|
||||
// Fall through with zeros if the RPC is missing.
|
||||
}
|
||||
|
||||
// Get active stops count
|
||||
let stopsQuery = `${supabaseUrl}/rest/v1/stops?select=id`;
|
||||
stopsQuery += `&date=gte.${new Date().toISOString().split("T")[0]}`;
|
||||
stopsQuery += `&status=eq.scheduled`;
|
||||
if (brandId) {
|
||||
stopsQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
|
||||
const stopsRes = await fetch(stopsQuery, {
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
Prefer: "count=exact",
|
||||
},
|
||||
});
|
||||
const activeStops = parseInt(stopsRes.headers.get("X-Total-Count") || "0");
|
||||
const stopsRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM stops
|
||||
WHERE date >= $1 AND status = 'scheduled' AND brand_id = $2`,
|
||||
[new Date().toISOString().split("T")[0], brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM stops
|
||||
WHERE date >= $1 AND status = 'scheduled'`,
|
||||
[new Date().toISOString().split("T")[0]],
|
||||
);
|
||||
const activeStops = stopsRes.rows.length;
|
||||
|
||||
// Get active products count
|
||||
let productsQuery = `${supabaseUrl}/rest/v1/products?select=id&active=eq.true`;
|
||||
if (brandId) {
|
||||
productsQuery += `&brand_id=eq.${brandId}`;
|
||||
}
|
||||
|
||||
const productsRes = await fetch(productsQuery, {
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
Prefer: "count=exact",
|
||||
},
|
||||
});
|
||||
const activeProducts = parseInt(productsRes.headers.get("X-Total-Count") || "0");
|
||||
const productsRes = brandId
|
||||
? await pool.query<{ id: string }>(
|
||||
`SELECT id FROM products WHERE active = true AND brand_id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
: await pool.query<{ id: string }>(
|
||||
`SELECT id FROM products WHERE active = true`,
|
||||
);
|
||||
const activeProducts = productsRes.rows.length;
|
||||
|
||||
return {
|
||||
total_revenue,
|
||||
@@ -295,4 +284,4 @@ function formatTimeAgo(dateString: string): string {
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user