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 { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -56,67 +53,66 @@ export type ConversionFunnel = {
|
|||||||
rate: number;
|
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,
|
* Aggregate KPIs over a date window. Replaces the
|
||||||
options?: RequestInit & { params?: Record<string, string> }
|
* `get_reports_summary` SECURITY DEFINER RPC from
|
||||||
): Promise<T> {
|
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
|
||||||
const adminUser = await getAdminUser();
|
* if the caller is unauthenticated or no orders exist in the window.
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
*/
|
||||||
|
async function getReportsSummary(
|
||||||
// brandId is available for future use in filtering
|
brandId: string | null,
|
||||||
void adminUser.brand_id;
|
startDate: string,
|
||||||
|
endDate: string
|
||||||
let url = `${supabaseUrl}/rest/v1${endpoint}`;
|
): Promise<{
|
||||||
if (options?.params) {
|
gross_sales: number;
|
||||||
const searchParams = new URLSearchParams(options.params);
|
total_orders: number;
|
||||||
url += `?${searchParams.toString()}`;
|
avg_order_value: number;
|
||||||
|
}> {
|
||||||
|
const params: unknown[] = [startDate, endDate];
|
||||||
|
let brandFilter = "";
|
||||||
|
if (brandId) {
|
||||||
|
brandFilter = `AND tenant_id = $3::uuid`;
|
||||||
|
params.push(brandId);
|
||||||
}
|
}
|
||||||
|
const { rows } = await pool.query<{
|
||||||
const response = await fetch(url, {
|
gross_sales: number;
|
||||||
...options,
|
total_orders: number;
|
||||||
headers: {
|
avg_order_value: number;
|
||||||
...svcHeaders(supabaseKey),
|
}>(
|
||||||
...options?.headers,
|
`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
|
||||||
if (!response.ok) {
|
FROM orders
|
||||||
const err = await response.text();
|
WHERE placed_at::date BETWEEN $1 AND $2
|
||||||
throw new Error(`Analytics fetch failed: ${err}`);
|
AND status <> 'canceled'
|
||||||
}
|
${brandFilter}`,
|
||||||
|
params
|
||||||
return response.json() as Promise<T>;
|
);
|
||||||
}
|
return rows[0] ?? { gross_sales: 0, total_orders: 0, avg_order_value: 0 };
|
||||||
|
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
// ── Analytics Actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
|
||||||
try {
|
try {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - periodDays);
|
startDate.setDate(startDate.getDate() - periodDays);
|
||||||
@@ -126,51 +122,42 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
|
|||||||
const prevStartDate = new Date(prevEndDate);
|
const prevStartDate = new Date(prevEndDate);
|
||||||
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
prevStartDate.setDate(prevStartDate.getDate() - periodDays);
|
||||||
|
|
||||||
// Current period
|
const [current, previous] = await Promise.all([
|
||||||
const current = await brandScopedRPC<{
|
getReportsSummary(brandId, startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]),
|
||||||
gross_sales: number;
|
getReportsSummary(brandId, prevStartDate.toISOString().split("T")[0], prevEndDate.toISOString().split("T")[0]),
|
||||||
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],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate changes
|
// Calculate changes
|
||||||
const calcChange = (current: number, previous: number) => {
|
const calcChange = (cur: number, prev: number) => {
|
||||||
if (previous === 0) return current > 0 ? 100 : 0;
|
if (prev === 0) return cur > 0 ? 100 : 0;
|
||||||
return Math.round(((current - previous) / previous) * 100 * 10) / 10;
|
return Math.round(((cur - prev) / prev) * 100 * 10) / 10;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get customer count
|
// Active customers (count of `customers` rows scoped to the brand).
|
||||||
const customers = await brandScopedFetch<{ count: number }[]>(
|
const customerParams: unknown[] = [];
|
||||||
"/communication_contacts",
|
let customerFilter = "";
|
||||||
{ params: { select: "count", limit: "1" } }
|
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 {
|
return {
|
||||||
total_revenue: current?.gross_sales ?? 0,
|
total_revenue: current.gross_sales,
|
||||||
revenue_change: calcChange(current?.gross_sales ?? 0, previous?.gross_sales ?? 0),
|
revenue_change: calcChange(current.gross_sales, previous.gross_sales),
|
||||||
total_orders: current?.total_orders ?? 0,
|
total_orders: current.total_orders,
|
||||||
orders_change: calcChange(current?.total_orders ?? 0, previous?.total_orders ?? 0),
|
orders_change: calcChange(current.total_orders, previous.total_orders),
|
||||||
active_customers: Array.isArray(customers) ? customers[0]?.count ?? 0 : 0,
|
active_customers,
|
||||||
customers_change: 0,
|
customers_change: 0,
|
||||||
avg_order_value: current?.avg_order_value ?? 0,
|
avg_order_value: current.avg_order_value,
|
||||||
aov_change: calcChange(current?.avg_order_value ?? 0, previous?.avg_order_value ?? 0),
|
aov_change: calcChange(current.avg_order_value, previous.avg_order_value),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch analytics metrics:", error);
|
console.error("Failed to fetch analytics metrics:", error);
|
||||||
// Return zeros on error
|
|
||||||
return {
|
return {
|
||||||
total_revenue: 0,
|
total_revenue: 0,
|
||||||
revenue_change: 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[]> {
|
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
|
||||||
try {
|
try {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const startDate = new Date();
|
const startDate = new Date();
|
||||||
startDate.setDate(startDate.getDate() - periodDays);
|
startDate.setDate(startDate.getDate() - periodDays);
|
||||||
|
|
||||||
const data = await brandScopedRPC<RevenueDataPoint[]>("get_revenue_chart", {
|
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
|
||||||
p_start_date: startDate.toISOString().split("T")[0],
|
let brandFilter = "";
|
||||||
p_end_date: endDate.toISOString().split("T")[0],
|
if (brandId) {
|
||||||
});
|
brandFilter = "AND tenant_id = $3::uuid";
|
||||||
|
params.push(brandId);
|
||||||
return data ?? [];
|
}
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch revenue chart:", error);
|
console.error("Failed to fetch revenue chart:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -204,23 +210,50 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
|
|||||||
|
|
||||||
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
|
||||||
try {
|
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;
|
product_name: string;
|
||||||
units_sold: number;
|
units_sold: number;
|
||||||
gross_revenue: number;
|
gross_revenue: number;
|
||||||
avg_price: number;
|
avg_price: number;
|
||||||
product_id: string;
|
}>(
|
||||||
}>>("get_sales_by_product_report", {
|
`SELECT
|
||||||
p_start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
p.id AS product_id,
|
||||||
p_end_date: new Date().toISOString().split("T")[0],
|
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 => ({
|
return rows.map((r) => ({
|
||||||
product_id: item.product_id ?? "",
|
product_id: r.product_id ?? "",
|
||||||
product_name: item.product_name ?? "Unknown Product",
|
product_name: r.product_name ?? "Unknown Product",
|
||||||
units_sold: item.units_sold ?? 0,
|
units_sold: r.units_sold ?? 0,
|
||||||
revenue: item.gross_revenue ?? 0,
|
revenue: r.gross_revenue ?? 0,
|
||||||
avg_price: item.avg_price ?? 0,
|
avg_price: r.avg_price ?? 0,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch top products:", error);
|
console.error("Failed to fetch top products:", error);
|
||||||
@@ -232,22 +265,46 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params: unknown[] = [];
|
||||||
select: "id,customer_name,subtotal,status,created_at,fulfillment",
|
let brandFilter = "";
|
||||||
order: "created_at.desc",
|
|
||||||
limit: limit.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (brandId) {
|
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 rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
return data ?? [];
|
customer_name: r.customer_name ?? "Unknown",
|
||||||
|
subtotal: r.subtotal,
|
||||||
|
status: r.status,
|
||||||
|
created_at: r.created_at,
|
||||||
|
fulfillment: r.fulfillment,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch recent orders:", error);
|
console.error("Failed to fetch recent orders:", error);
|
||||||
return [];
|
return [];
|
||||||
@@ -256,26 +313,37 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
|
|||||||
|
|
||||||
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
|
||||||
try {
|
try {
|
||||||
const result = await brandScopedRPC<{
|
const adminUser = await getAdminUser();
|
||||||
total: number;
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
new_contacts: number;
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
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],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get total customers
|
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
|
||||||
const totalCustomers = await brandScopedFetch<{ count: number }[]>(
|
const endDate = new Date().toISOString().split("T")[0];
|
||||||
"/communication_contacts",
|
const params: unknown[] = [startDate, endDate];
|
||||||
{ params: { select: "count", limit: "1" } }
|
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 {
|
return {
|
||||||
total_customers: Array.isArray(totalCustomers) ? totalCustomers[0]?.count ?? 0 : 0,
|
total_customers: total,
|
||||||
new_this_month: result?.new_contacts ?? 0,
|
new_this_month: newThisMonth,
|
||||||
retention_rate: 85,
|
retention_rate: 85,
|
||||||
growth_rate: result?.growth_rate ?? 0,
|
growth_rate: Math.round(growthRate * 10) / 10,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch customer growth:", 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[]> {
|
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
|
||||||
// Return a standardized funnel based on order data
|
|
||||||
try {
|
try {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
|
|
||||||
const brandId = await getActiveBrandId(adminUser);
|
const brandId = await getActiveBrandId(adminUser);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params: unknown[] = [];
|
||||||
select: "id,status",
|
let brandFilter = "";
|
||||||
limit: "1000",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (brandId) {
|
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 = rows.length;
|
||||||
|
|
||||||
const total = orders?.length ?? 0;
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return [
|
return [
|
||||||
{ stage: "Visitors", count: 0, rate: 0 },
|
{ 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 checkout = Math.round(purchased * 1.5);
|
||||||
const addToCart = Math.round(checkout * 2.6);
|
const addToCart = Math.round(checkout * 2.6);
|
||||||
const productViews = Math.round(addToCart * 2.7);
|
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);
|
console.error("Failed to fetch conversion funnel:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { withTx, pool } from "@/lib/db";
|
||||||
|
import { orders, orderItems, customers } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export type ImportOrdersResult =
|
export type ImportOrdersResult =
|
||||||
| { success: true; imported: number; errors: { row: number; error: string }[] }
|
| { success: true; imported: number; errors: { row: number; error: string }[] }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
|
||||||
|
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new
|
||||||
|
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`,
|
||||||
|
* `customer_phone`, or `stop_id` columns — totals are stored in
|
||||||
|
* `total_cents` and customers are referenced by `customer_id`. For each
|
||||||
|
* imported order we upsert a `customers` row keyed on email+tenant, then
|
||||||
|
* insert the `orders` + `order_items` rows.
|
||||||
|
*/
|
||||||
export async function importOrdersBatch(
|
export async function importOrdersBatch(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
orders: Array<{
|
ordersToImport: Array<{
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
customer_email: string;
|
customer_email: string;
|
||||||
customer_phone: string;
|
customer_phone: string;
|
||||||
@@ -25,40 +36,84 @@ export async function importOrdersBatch(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const results = { imported: 0, errors: [] as { row: number; error: string }[] };
|
const results = { imported: 0, errors: [] as { row: number; error: string }[] };
|
||||||
|
|
||||||
for (const order of orders) {
|
for (let i = 0; i < ordersToImport.length; i++) {
|
||||||
const idempotencyKey = crypto.randomUUID();
|
const order = ordersToImport[i];
|
||||||
|
try {
|
||||||
const response = await fetch(
|
// Compute total_cents server-side from current product prices.
|
||||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
const productIds = order.items.map((it) => it.product_id);
|
||||||
{
|
if (productIds.length === 0) {
|
||||||
method: "POST",
|
results.errors.push({ row: i, error: "Order has no items" });
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
continue;
|
||||||
body: JSON.stringify({
|
|
||||||
p_idempotency_key: idempotencyKey,
|
|
||||||
p_customer_name: order.customer_name,
|
|
||||||
p_customer_email: order.customer_email,
|
|
||||||
p_customer_phone: order.customer_phone,
|
|
||||||
p_stop_id: order.stop_id,
|
|
||||||
p_items: order.items.map((it) => ({
|
|
||||||
id: it.product_id,
|
|
||||||
quantity: it.quantity,
|
|
||||||
fulfillment: it.fulfillment,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// Fetch product prices for the brand.
|
||||||
results.errors.push({ row: 0, error: `Failed to import order for ${order.customer_name}` });
|
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
||||||
} else {
|
`SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
|
||||||
|
[brandId, productIds]
|
||||||
|
);
|
||||||
|
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
|
||||||
|
|
||||||
|
let totalCents = 0;
|
||||||
|
for (const it of order.items) {
|
||||||
|
const unit = priceMap.get(it.product_id);
|
||||||
|
if (typeof unit !== "number") {
|
||||||
|
results.errors.push({ row: i, error: `Unknown product: ${it.product_id}` });
|
||||||
|
totalCents = -1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
totalCents += unit * it.quantity;
|
||||||
|
}
|
||||||
|
if (totalCents < 0) continue;
|
||||||
|
|
||||||
|
// Determine fulfillment: pickup | ship | mixed based on items.
|
||||||
|
const fulfillments = new Set(order.items.map((it) => it.fulfillment));
|
||||||
|
const fulfillment: "pickup" | "ship" | "mixed" =
|
||||||
|
fulfillments.size > 1
|
||||||
|
? "mixed"
|
||||||
|
: (Array.from(fulfillments)[0] as "pickup" | "ship" | "mixed") ?? "pickup";
|
||||||
|
|
||||||
|
// Insert in a single transaction: customers + orders + order_items.
|
||||||
|
await withTx(async (client) => {
|
||||||
|
// Upsert customer by (tenant_id, email).
|
||||||
|
const customerRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
|
||||||
|
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
|
||||||
|
);
|
||||||
|
const customerId = customerRes.rows[0]?.id ?? null;
|
||||||
|
|
||||||
|
const orderRes = await client.query<{ id: string }>(
|
||||||
|
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
|
||||||
|
VALUES ($1, $2, $3, 'pending', $4)
|
||||||
|
RETURNING id`,
|
||||||
|
[brandId, customerId, totalCents, fulfillment]
|
||||||
|
);
|
||||||
|
const orderId = orderRes.rows[0]?.id;
|
||||||
|
if (!orderId) throw new Error("Order insert returned no id");
|
||||||
|
|
||||||
|
for (const it of order.items) {
|
||||||
|
const unit = priceMap.get(it.product_id) ?? 0;
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[orderId, it.product_id, it.quantity, unit, it.fulfillment === "shipping" ? "ship" : it.fulfillment]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
results.imported++;
|
results.imported++;
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.push({
|
||||||
|
row: i,
|
||||||
|
error: `Failed to import order for ${order.customer_name}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, imported: results.imported, errors: results.errors };
|
return { success: true, imported: results.imported, errors: results.errors };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { withTenant } from "@/db/client";
|
||||||
|
import { products } from "@/db/schema";
|
||||||
|
|
||||||
export type ImportProductsResult =
|
export type ImportProductsResult =
|
||||||
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-import products. Replaces the legacy `bulk_upsert_products` SECURITY
|
||||||
|
* DEFINER RPC. The new `products` schema drops the legacy `type`, `is_taxable`,
|
||||||
|
* `pickup_type`, and `image_url` columns; we keep `name`, `description`,
|
||||||
|
* `price_cents`, and `active`. Without an id we always INSERT (no upsert
|
||||||
|
* key for matching — the caller can run an update path separately if
|
||||||
|
* deduplication is needed).
|
||||||
|
*/
|
||||||
export async function importProductsBatch(
|
export async function importProductsBatch(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
products: Array<{
|
productsToImport: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -26,19 +35,33 @@ export async function importProductsBatch(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let created = 0;
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const errors: { product: string; error: string }[] = [];
|
||||||
|
|
||||||
const response = await fetch(
|
for (const p of productsToImport) {
|
||||||
`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`,
|
const priceCents = Math.round(Number(p.price) * 100);
|
||||||
{
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
method: "POST",
|
errors.push({ product: p.name, error: "Invalid price" });
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
continue;
|
||||||
body: JSON.stringify({ p_brand_id: brandId, p_products: products }),
|
|
||||||
}
|
}
|
||||||
);
|
try {
|
||||||
|
await withTenant(brandId, (db) =>
|
||||||
|
db.insert(products).values({
|
||||||
|
tenantId: brandId,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description ?? null,
|
||||||
|
priceCents,
|
||||||
|
active: p.active,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({
|
||||||
|
product: p.name,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Import failed" };
|
return { success: true, created, updated: 0, errors };
|
||||||
const data = await response.json();
|
}
|
||||||
return { success: true, created: data.created, updated: data.updated, errors: data.errors ?? [] };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
import { getMockTableData } from "@/lib/mock-data";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { withTenant } from "@/db/client";
|
||||||
|
import { products } from "@/db/schema";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
@@ -50,37 +51,33 @@ export async function createProduct(
|
|||||||
return { success: true, id: newId };
|
return { success: true, id: newId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// The new schema stores products with `price_cents` (integer) and no `type`,
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
|
||||||
|
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, {
|
// aren't part of the SaaS schema and are dropped. `active` and `description`
|
||||||
method: "POST",
|
// exist as `active` and `description` columns.
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
const priceCents = Math.round(Number(data.price) * 100);
|
||||||
signal: AbortSignal.timeout(10000),
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
body: JSON.stringify({
|
return { success: false, error: "Invalid price" };
|
||||||
p_brand_id: brandId,
|
|
||||||
p_products: [{
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
price: data.price,
|
|
||||||
type: data.type,
|
|
||||||
active: data.active,
|
|
||||||
image_url: data.image_url ?? null,
|
|
||||||
is_taxable: data.is_taxable,
|
|
||||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
return { success: false, error: `Failed: ${err}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await res.json();
|
try {
|
||||||
if (result.errors && result.errors.length > 0) {
|
const inserted = await withTenant(brandId, async (db) => {
|
||||||
return { success: false, error: result.errors[0].error };
|
const [row] = await db
|
||||||
|
.insert(products)
|
||||||
|
.values({
|
||||||
|
tenantId: brandId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
priceCents,
|
||||||
|
active: data.active,
|
||||||
|
})
|
||||||
|
.returning({ id: products.id });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
if (!inserted) return { success: false, error: "Insert returned no row" };
|
||||||
|
return { success: true, id: inserted.id };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: `Failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, id: result.created > 0 ? "created" : "updated" };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getMockTableData } from "@/lib/mock-data";
|
import { getMockTableData } from "@/lib/mock-data";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { withTenant } from "@/db/client";
|
||||||
|
import { products } from "@/db/schema";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
|
||||||
|
|
||||||
@@ -36,33 +38,30 @@ export async function updateProduct(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// The new schema has `price_cents` (integer cents) and no `type`,
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
|
||||||
|
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, {
|
// `image_url` lives in `product_images`.
|
||||||
method: "PATCH",
|
const priceCents = Math.round(Number(data.price) * 100);
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" },
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
body: JSON.stringify({
|
return { success: false, error: "Invalid price" };
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
price: data.price,
|
|
||||||
type: data.type,
|
|
||||||
active: data.active,
|
|
||||||
image_url: data.image_url ?? null,
|
|
||||||
is_taxable: data.is_taxable,
|
|
||||||
pickup_type: data.pickup_type ?? "scheduled_stop",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
return { success: false, error: `Failed to update product: ${err}` };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await res.json();
|
try {
|
||||||
if (updated.errors) {
|
await withTenant(brandId, (db) =>
|
||||||
return { success: false, error: updated.errors[0]?.message ?? "Unknown error" };
|
db
|
||||||
|
.update(products)
|
||||||
|
.set({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
priceCents,
|
||||||
|
active: data.active,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: `Failed to update product: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { withTenant } from "@/db/client";
|
||||||
|
import { productImages } from "@/db/schema";
|
||||||
// Product images bucket - UUID from Supabase
|
|
||||||
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
|
|
||||||
|
|
||||||
export type UploadProductImageResult =
|
export type UploadProductImageResult =
|
||||||
| { success: true; imageUrl: string }
|
| { success: true; imageUrl: string }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// TODO(migration): product images in the new SaaS schema live in the
|
||||||
|
// `product_images` table (storage_key + position + alt_text) backed by
|
||||||
|
// the `files` table. Supabase Storage is gone, so we no longer upload
|
||||||
|
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
|
||||||
|
// upload images should write to the `files` table via an S3-compatible
|
||||||
|
// backend (still TODO). This stub persists the intended storage key as
|
||||||
|
// a record so the UI can continue to render an image URL placeholder.
|
||||||
export async function uploadProductImage(
|
export async function uploadProductImage(
|
||||||
productId: string,
|
productId: string,
|
||||||
file: File
|
file: File
|
||||||
@@ -26,54 +31,29 @@ export async function uploadProductImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||||
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
// Without a configured object store, we cannot actually upload bytes.
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
// We still record the planned storage key in `product_images` so the
|
||||||
|
// schema-level FK + ordering are exercised. The actual upload will be
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// re-introduced when the S3-compatible store lands.
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"apikey": supabaseKey,
|
|
||||||
"Authorization": `Bearer ${supabaseKey}`,
|
|
||||||
"Content-Type": `image/${ext}`,
|
|
||||||
"x-upsert": "true"
|
|
||||||
},
|
|
||||||
body: buffer,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
|
|
||||||
|
|
||||||
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
|
||||||
if (productId === "__NEW__") {
|
if (productId === "__NEW__") {
|
||||||
return { success: true, imageUrl: publicUrl };
|
return { success: false, error: "Object store not configured; cannot upload images yet" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update product record with new image URL
|
try {
|
||||||
const patchRes = await fetch(
|
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
||||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
db.insert(productImages).values({
|
||||||
{
|
productId,
|
||||||
method: "PATCH",
|
storageKey,
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
position: 0,
|
||||||
body: JSON.stringify({ image_url: publicUrl }),
|
altText: null,
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
return { success: true, imageUrl: `/storage/${storageKey}` };
|
||||||
if (!patchRes.ok) {
|
} catch (err) {
|
||||||
return { success: false, error: "Upload succeeded but failed to save image URL" };
|
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, imageUrl: publicUrl };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProductImage(
|
export async function deleteProductImage(
|
||||||
@@ -82,21 +62,18 @@ export async function deleteProductImage(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// In the new schema, "clearing" an image means removing the row(s)
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
// from `product_images` for this product. The legacy `image_url` PATCH
|
||||||
|
// pathway is gone (that column no longer exists on `products`).
|
||||||
const patchRes = await fetch(
|
try {
|
||||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
|
||||||
{
|
db.delete(productImages).where(eq(productImages.productId, productId))
|
||||||
method: "PATCH",
|
);
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
return { success: true };
|
||||||
body: JSON.stringify({ image_url: null }),
|
} catch (err) {
|
||||||
}
|
return { success: false, error: `Failed to clear image: ${err instanceof Error ? err.message : String(err)}` };
|
||||||
);
|
|
||||||
|
|
||||||
if (!patchRes.ok) {
|
|
||||||
return { success: false, error: "Failed to clear image" };
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
// Imported lazily to avoid a circular dep with the table ref above.
|
||||||
}
|
import { eq } from "drizzle-orm";
|
||||||
|
|||||||
+161
-63
@@ -1,10 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
export type DateRange = { start: string; end: string };
|
export type DateRange = { start: string; end: string };
|
||||||
|
|
||||||
@@ -73,87 +70,188 @@ export type CampaignActivityRow = {
|
|||||||
messages_logged: number;
|
messages_logged: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Internal fetch helper ────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.)
|
||||||
|
// from `supabase/migrations/031_reports_v1_rpcs.sql` reference legacy columns
|
||||||
|
// (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
|
||||||
|
// The SaaS rebuild has a different `orders` schema (`total_cents`, no
|
||||||
|
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
|
||||||
|
// (`customers` instead of `communication_contacts`). The implementations below
|
||||||
|
// preserve the same return shape but read from the new tables; some
|
||||||
|
// fields gracefully degrade to 0 when the underlying data isn't there yet
|
||||||
|
// (e.g. `stop_name` joins fall back to "—").
|
||||||
|
|
||||||
async function reportRPC<T>(
|
/** Substitute the brandId into the SQL — null = platform admin (all brands). */
|
||||||
rpcName: string,
|
function brandClause(brandId: string | null, tableAlias = "o"): string {
|
||||||
params: { p_start_date: string; p_end_date: string },
|
return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
|
||||||
forceBrandId: string | null
|
}
|
||||||
): Promise<T> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) throw new Error("Not authenticated");
|
|
||||||
|
|
||||||
// brand_admin: always enforce their assigned brand (ignore UI selection)
|
function brandParams(brandId: string | null): unknown[] {
|
||||||
// platform_admin: use forceBrandId (null = all brands)
|
return brandId ? [brandId] : [];
|
||||||
const brandId = adminUser.role === "brand_admin"
|
|
||||||
? adminUser.brand_id
|
|
||||||
: forceBrandId;
|
|
||||||
|
|
||||||
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,
|
|
||||||
p_start_date: params.p_start_date,
|
|
||||||
p_end_date: params.p_end_date,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
throw new Error(`RPC ${rpcName} failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Report actions ──────────────────────────────────────────────────────────
|
// ── Report actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
|
export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<ReportsSummary>("get_reports_summary", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
const { rows } = await pool.query<ReportsSummary>(
|
||||||
|
`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,
|
||||||
|
0::int AS pickup_orders,
|
||||||
|
0::int AS shipping_orders,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending' AND fulfillment IN ('pickup', 'mixed'))::int AS pending_pickups,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'fulfilled' AND fulfillment IN ('pickup', 'mixed'))::int AS completed_pickups,
|
||||||
|
0::int AS contacts_added,
|
||||||
|
0::int AS campaigns_sent,
|
||||||
|
0::int AS messages_logged
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.placed_at::date BETWEEN $1 AND $2
|
||||||
|
AND o.status <> 'canceled'
|
||||||
|
${brandClause(effectiveBrandId)}`,
|
||||||
|
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||||
|
);
|
||||||
|
return rows[0] ?? {
|
||||||
|
gross_sales: 0,
|
||||||
|
total_orders: 0,
|
||||||
|
avg_order_value: 0,
|
||||||
|
pickup_orders: 0,
|
||||||
|
shipping_orders: 0,
|
||||||
|
pending_pickups: 0,
|
||||||
|
completed_pickups: 0,
|
||||||
|
contacts_added: 0,
|
||||||
|
campaigns_sent: 0,
|
||||||
|
messages_logged: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
|
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<OrderByStop[]>("get_orders_by_stop_report", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
// The new `stops` table doesn't have city/state/date columns. The reports
|
||||||
|
// page is dormant; return an empty list to keep the API surface intact.
|
||||||
|
void range;
|
||||||
|
void effectiveBrandId;
|
||||||
|
return [] as OrderByStop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
|
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<SalesByProduct[]>("get_sales_by_product_report", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
product_name: string;
|
||||||
|
units_sold: number;
|
||||||
|
gross_revenue: number;
|
||||||
|
avg_price: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
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'
|
||||||
|
${brandClause(effectiveBrandId, "o")}
|
||||||
|
GROUP BY p.id, p.name
|
||||||
|
ORDER BY gross_revenue DESC`,
|
||||||
|
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
|
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<FulfillmentRow[]>("get_fulfillment_report", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
fulfillment_type: string;
|
||||||
|
order_count: number;
|
||||||
|
revenue: number;
|
||||||
|
pct_of_total: number;
|
||||||
|
}>(
|
||||||
|
`WITH base AS (
|
||||||
|
SELECT
|
||||||
|
fulfillment,
|
||||||
|
total_cents::float / 100.0 AS revenue_cents
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.placed_at::date BETWEEN $1 AND $2
|
||||||
|
AND o.status <> 'canceled'
|
||||||
|
${brandClause(effectiveBrandId)}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
fulfillment AS fulfillment_type,
|
||||||
|
COUNT(*)::int AS order_count,
|
||||||
|
COALESCE(SUM(revenue_cents), 0)::float AS revenue,
|
||||||
|
CASE WHEN SUM(SUM(revenue_cents)) OVER () > 0
|
||||||
|
THEN ROUND((SUM(revenue_cents) / SUM(SUM(revenue_cents)) OVER () * 100)::numeric, 1)::float
|
||||||
|
ELSE 0
|
||||||
|
END AS pct_of_total
|
||||||
|
FROM base
|
||||||
|
GROUP BY fulfillment
|
||||||
|
ORDER BY revenue DESC`,
|
||||||
|
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
|
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<PickupStatusByStop[]>("get_pickup_status_by_stop", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
// The new `stops` table doesn't carry city/date columns. Return an empty
|
||||||
|
// list so the page renders gracefully until the stop schema is rehydrated.
|
||||||
|
void range;
|
||||||
|
void effectiveBrandId;
|
||||||
|
return [] as PickupStatusByStop[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
|
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<ContactGrowthRow[]>("get_contact_growth_report", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
|
// `customers` replaced `communication_contacts`. `source` column is gone,
|
||||||
|
// so `imports` is always 0 and `new_contacts` is the day's net add.
|
||||||
|
const { rows } = await pool.query<{ date: string; new_contacts: number; imports: number; total: number }>(
|
||||||
|
`SELECT
|
||||||
|
d::date::text AS date,
|
||||||
|
COUNT(c.id) FILTER (WHERE c.created_at::date = d::date)::int AS new_contacts,
|
||||||
|
0::int AS imports,
|
||||||
|
COUNT(c.id) OVER (ORDER BY d::date)::int AS total
|
||||||
|
FROM generate_series($1::date, $2::date, '1 day'::interval) d
|
||||||
|
LEFT JOIN customers c
|
||||||
|
ON c.created_at::date = d::date
|
||||||
|
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
|
||||||
|
GROUP BY d::date
|
||||||
|
ORDER BY d::date DESC`,
|
||||||
|
[range.start, range.end, ...brandParams(effectiveBrandId)]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
|
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
|
||||||
return reportRPC<CampaignActivityRow[]>("get_campaign_activity_report", {
|
const adminUser = await getAdminUser();
|
||||||
p_start_date: range.start,
|
if (!adminUser) throw new Error("Not authenticated");
|
||||||
p_end_date: range.end,
|
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
|
||||||
}, brandId);
|
|
||||||
}
|
// The legacy `communication_campaigns` table is gone (replaced by
|
||||||
|
// `campaigns` + `email_templates`). The reports page is dormant; return
|
||||||
|
// an empty list to keep the API surface intact.
|
||||||
|
void range;
|
||||||
|
void effectiveBrandId;
|
||||||
|
return [] as CampaignActivityRow[];
|
||||||
|
}
|
||||||
|
|||||||
+139
-316
@@ -2,23 +2,14 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
|
||||||
const SUPABASE_PAT = process.env.SUPABASE_PAT!;
|
// tables from the route-trace feature (defined across
|
||||||
|
// supabase/migrations/143_enable_route_trace.sql and friends) were retired
|
||||||
async function adminFetch(endpoint: string, options?: RequestInit) {
|
// from the SaaS rebuild — they don't exist in `db/schema/`. The functions
|
||||||
const res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${endpoint}`, {
|
// below all return empty results so the public trace page and FSMA reports
|
||||||
...options,
|
// 404 gracefully. If route-trace comes back, re-introduce the tables in
|
||||||
headers: {
|
// `db/schema/` and replace these stubs with real Drizzle queries.
|
||||||
...svcHeaders(SUPABASE_PAT),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HarvestLot {
|
export interface HarvestLot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -151,72 +142,72 @@ export interface FieldYieldSummary {
|
|||||||
yield_unit: string | null;
|
yield_unit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRouteTraceLots(brandId: string, status?: string) {
|
// Discriminated unions so TypeScript narrows correctly for consumer patterns:
|
||||||
|
// - `result.success ? result.X : defaultValue` (X is defined on success)
|
||||||
|
// - `if (result.success && result.X) { ... } else { result.error }` (error readable in else)
|
||||||
|
type LotsResult =
|
||||||
|
| { success: true; lots: HaulingLot[]; error: null }
|
||||||
|
| { success: false; lots: null; error: string };
|
||||||
|
type HarvestLotsResult =
|
||||||
|
| { success: true; lots: HarvestLot[]; error: null }
|
||||||
|
| { success: false; lots: null; error: string };
|
||||||
|
type StatsResult =
|
||||||
|
| { success: true; stats: RouteTraceStats; error: null }
|
||||||
|
| { success: false; stats: null; error: string };
|
||||||
|
type LotDetailResult =
|
||||||
|
| { success: true; lot: LotDetail; error: null }
|
||||||
|
| { success: false; lot: null; error: string };
|
||||||
|
type CreatedLotResult =
|
||||||
|
| { success: true; lot: HarvestLot; error: null }
|
||||||
|
| { success: false; lot: null; error: string };
|
||||||
|
type UpdatedLotResult =
|
||||||
|
| { success: true; lot: HarvestLot | null; error: null }
|
||||||
|
| { success: false; lot: null; error: string };
|
||||||
|
type InventoryResult =
|
||||||
|
| { success: true; inventory: InventoryByCrop[]; error: null }
|
||||||
|
| { success: false; inventory: null; error: string };
|
||||||
|
type YieldResult =
|
||||||
|
| { success: true; summary: FieldYieldSummary[]; error: null }
|
||||||
|
| { success: false; summary: null; error: string };
|
||||||
|
type EventsResult =
|
||||||
|
| { success: true; events: RecentLotEvent[]; error: null }
|
||||||
|
| { success: false; events: null; error: string };
|
||||||
|
type OrdersResult =
|
||||||
|
| { success: true; orders: LotOrder[]; error: null }
|
||||||
|
| { success: false; orders: null; error: string };
|
||||||
|
type ChainResult =
|
||||||
|
| { success: true; chain: TraceChain; error: null }
|
||||||
|
| { success: false; chain: null; error: string };
|
||||||
|
|
||||||
|
async function assertCanAccessBrand(brandId: string): Promise<
|
||||||
|
{ ok: true; adminUser: NonNullable<Awaited<ReturnType<typeof getAdminUser>>> } | { ok: false; error: string }
|
||||||
|
> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { ok: false, error: "Unauthorized" };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||||
return { success: false, error: "Brand access required" };
|
return { ok: false, error: "Brand access required" };
|
||||||
}
|
}
|
||||||
if (activeBrandId) {
|
if (activeBrandId) {
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
try {
|
||||||
}
|
assertBrandAccess(adminUser, activeBrandId);
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
} catch {
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
return { ok: false, error: "Brand access denied" };
|
||||||
|
}
|
||||||
try {
|
|
||||||
const data = await adminFetch("get_harvest_lots", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_status: status ?? null }),
|
|
||||||
});
|
|
||||||
return { success: true, lots: data };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
}
|
||||||
|
return { ok: true, adminUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRouteTraceLotDetail(lotId: string) {
|
export async function getRouteTraceLots(brandId: string, _status?: string): Promise<LotsResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, lots: null, error: auth.error };
|
||||||
|
return { success: true, lots: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
export async function getRouteTraceLotDetail(_lotId: string): Promise<LotDetailResult> {
|
||||||
const data = await adminFetch("get_harvest_lot_detail", {
|
const adminUser = await getAdminUser();
|
||||||
method: "POST",
|
if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
|
||||||
body: JSON.stringify({ p_lot_id: lotId }),
|
return { success: false, lot: null, error: "Route-trace feature not configured" };
|
||||||
});
|
|
||||||
if (!data || data.length === 0) return { success: false, error: "Lot not found" };
|
|
||||||
const row = data[0];
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
lot: {
|
|
||||||
lot_id: row.lot_id,
|
|
||||||
lot_number: row.lot_number,
|
|
||||||
crop_type: row.crop_type,
|
|
||||||
variety: row.variety ?? null,
|
|
||||||
harvest_date: row.harvest_date,
|
|
||||||
field_location: row.field_location,
|
|
||||||
worker_name: row.worker_name,
|
|
||||||
packer_name: row.packer_name ?? null,
|
|
||||||
quantity_lbs: row.quantity_lbs,
|
|
||||||
quantity_used_lbs: row.quantity_used_lbs ?? null,
|
|
||||||
status: row.status,
|
|
||||||
notes: row.notes,
|
|
||||||
source_stop_id: row.source_stop_id,
|
|
||||||
destination_stop_id: row.destination_stop_id,
|
|
||||||
created_at: row.created_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
bin_id: row.bin_id ?? null,
|
|
||||||
container_id: row.container_id ?? null,
|
|
||||||
field_block: row.field_block ?? null,
|
|
||||||
pallets: row.pallets ?? null,
|
|
||||||
yield_estimate_lbs: row.yield_estimate_lbs ?? null,
|
|
||||||
yield_unit: row.yield_unit ?? null,
|
|
||||||
events: row.events ?? [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLotData {
|
export interface CreateLotData {
|
||||||
@@ -239,201 +230,70 @@ export interface CreateLotData {
|
|||||||
|
|
||||||
export async function createHarvestLot(
|
export async function createHarvestLot(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
data: CreateLotData
|
_data: CreateLotData
|
||||||
) {
|
): Promise<CreatedLotResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, lot: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: false, lot: null, error: "Route-trace feature not configured" };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await adminFetch("create_harvest_lot", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: effectiveBrandId,
|
|
||||||
p_crop_type: data.crop_type,
|
|
||||||
p_variety: data.variety ?? null,
|
|
||||||
p_harvest_date: data.harvest_date,
|
|
||||||
p_field_location: data.field_location ?? null,
|
|
||||||
p_worker_name: data.worker_name ?? null,
|
|
||||||
p_packer_name: data.packer_name ?? null,
|
|
||||||
p_quantity_lbs: data.quantity_lbs ?? null,
|
|
||||||
p_notes: data.notes ?? null,
|
|
||||||
p_destination_stop_id: data.destination_stop_id ?? null,
|
|
||||||
p_admin_id: adminUser.id ?? null,
|
|
||||||
p_bin_id: data.bin_id ?? null,
|
|
||||||
p_container_id: data.container_id ?? null,
|
|
||||||
p_field_block: data.field_block ?? null,
|
|
||||||
p_yield_estimate_lbs: data.yield_estimate_lbs ?? null,
|
|
||||||
p_yield_unit: data.yield_unit ?? null,
|
|
||||||
p_pallets: data.pallets ?? null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return { success: true, lot: result };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateHarvestLotStatus(
|
export async function updateHarvestLotStatus(
|
||||||
lotId: string,
|
_lotId: string,
|
||||||
status: string,
|
_status: string,
|
||||||
location?: string,
|
_location?: string,
|
||||||
notes?: string,
|
_notes?: string,
|
||||||
binId?: string
|
_binId?: string
|
||||||
) {
|
): Promise<UpdatedLotResult> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
|
||||||
|
return { success: false, lot: null, error: "Route-trace feature not configured" };
|
||||||
try {
|
|
||||||
const result = await adminFetch("update_harvest_lot_status", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_lot_id: lotId,
|
|
||||||
p_status: status,
|
|
||||||
p_location: location ?? null,
|
|
||||||
p_notes: notes ?? null,
|
|
||||||
p_admin_id: adminUser.id ?? null,
|
|
||||||
...(binId ? { p_bin_id: binId } : {}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return { success: true, lot: result };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRouteTraceStats(brandId: string) {
|
export async function getRouteTraceStats(brandId: string): Promise<StatsResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, stats: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return {
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
success: true,
|
||||||
return { success: false, error: "Brand access required" };
|
stats: {
|
||||||
}
|
active_count: 0,
|
||||||
if (activeBrandId) {
|
in_transit_count: 0,
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
at_shed_count: 0,
|
||||||
}
|
total_lots_today: 0,
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
total_harvested_today: 0,
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
total_lots: 0,
|
||||||
|
},
|
||||||
try {
|
error: null,
|
||||||
const data = await adminFetch("get_route_trace_stats", {
|
};
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
|
||||||
});
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return { success: true, stats: { active_count: 0, in_transit_count: 0, at_shed_count: 0, total_lots_today: 0, total_harvested_today: 0 } };
|
|
||||||
}
|
|
||||||
return { success: true, stats: data[0] };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchHarvestLots(brandId: string, query: string) {
|
export async function searchHarvestLots(brandId: string, _query: string): Promise<HarvestLotsResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, lots: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: true, lots: [], error: null };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("search_harvest_lots", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_query: query }),
|
|
||||||
});
|
|
||||||
return { success: true, lots: data };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTraceChain(lotId: string) {
|
export async function getTraceChain(_lotId: string): Promise<ChainResult> {
|
||||||
try {
|
const adminUser = await getAdminUser();
|
||||||
const data = await adminFetch("get_trace_chain", {
|
if (!adminUser) return { success: false, chain: null, error: "Unauthorized" };
|
||||||
method: "POST",
|
return { success: false, chain: null, error: "Route-trace feature not configured" };
|
||||||
body: JSON.stringify({ p_lot_id: lotId }),
|
|
||||||
});
|
|
||||||
return { success: true, chain: data };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHarvestLotsReadyToHaul(brandId: string) {
|
export async function getHarvestLotsReadyToHaul(brandId: string): Promise<LotsResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, lots: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: true, lots: [], error: null };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("get_harvest_lots_ready_to_haul", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
|
||||||
});
|
|
||||||
return { success: true, lots: data };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFieldYieldSummary(brandId: string) {
|
export async function getFieldYieldSummary(brandId: string): Promise<YieldResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, summary: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: true, summary: [], error: null };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("get_field_yield_summary", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
|
||||||
});
|
|
||||||
return { success: true, summary: data };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLotOrders(lotId: string): Promise<{ success: true; orders: LotOrder[] } | { success: false; error: string }> {
|
export async function getLotOrders(_lotId: string): Promise<OrdersResult> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, orders: null, error: "Unauthorized" };
|
||||||
|
return { success: true, orders: [], error: null };
|
||||||
try {
|
|
||||||
const data = await adminFetch("get_lot_orders", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_lot_id: lotId }),
|
|
||||||
});
|
|
||||||
return { success: true, orders: data ?? [] };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryByCrop {
|
export interface InventoryByCrop {
|
||||||
@@ -445,79 +305,42 @@ export interface InventoryByCrop {
|
|||||||
yield_unit: string | null;
|
yield_unit: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInventoryByCrop(brandId: string): Promise<{ success: true; inventory: InventoryByCrop[] } | { success: false; error: string }> {
|
export async function getInventoryByCrop(brandId: string): Promise<InventoryResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, inventory: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: true, inventory: [], error: null };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await adminFetch("get_inventory_by_crop", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId }),
|
|
||||||
});
|
|
||||||
return { success: true, inventory: data ?? [] };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markLotUsedInOrder(
|
export async function markLotUsedInOrder(
|
||||||
lotId: string,
|
_lotId: string,
|
||||||
orderId: string,
|
_orderId: string,
|
||||||
quantityToAdd?: number,
|
_quantityToAdd?: number,
|
||||||
notes?: string
|
_notes?: string
|
||||||
): Promise<{ success: true } | { success: false; error: string }> {
|
): Promise<{ success: true } | { success: false; error: string }> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||||
|
return { success: false, error: "Route-trace feature not configured" };
|
||||||
try {
|
|
||||||
await adminFetch("mark_lot_used_in_order", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_lot_id: lotId,
|
|
||||||
p_order_id: orderId,
|
|
||||||
p_quantity_to_add: quantityToAdd ?? null,
|
|
||||||
p_notes: notes ?? null,
|
|
||||||
p_admin_id: adminUser.id ?? null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: String(e) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentLotEvents(
|
export async function getRecentLotEvents(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
limit = 10
|
_limit = 10
|
||||||
): Promise<{ success: true; events: RecentLotEvent[] } | { success: false; error: string }> {
|
): Promise<EventsResult> {
|
||||||
const adminUser = await getAdminUser();
|
const auth = await assertCanAccessBrand(brandId);
|
||||||
if (!adminUser) return { success: false, error: "Unauthorized" };
|
if (!auth.ok) return { success: false, events: null, error: auth.error };
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
return { success: true, events: [], error: null };
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
}
|
||||||
return { success: false, error: "Brand access required" };
|
|
||||||
}
|
|
||||||
if (activeBrandId) {
|
|
||||||
try { assertBrandAccess(adminUser, activeBrandId); } catch { return { success: false, error: "Brand access denied" }; }
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? undefined;
|
|
||||||
if (!effectiveBrandId) return { success: false, error: "No brand" };
|
|
||||||
|
|
||||||
try {
|
/**
|
||||||
const data = await adminFetch("get_recent_lot_events", {
|
* Look up a harvest lot by its public-facing lot number (e.g. "FL-2026-001").
|
||||||
method: "POST",
|
* Returns the lot's UUID or `null` if no such lot exists.
|
||||||
body: JSON.stringify({ p_brand_id: effectiveBrandId, p_limit: limit }),
|
*
|
||||||
});
|
* NOTE: the `harvest_lots` table is not in the new Drizzle schema (it's a
|
||||||
return { success: true, events: data ?? [] };
|
* niche traceability feature that was retired from the SaaS rebuild). This
|
||||||
} catch (e: unknown) {
|
* stub returns `null` so the public trace page gracefully 404s. If the
|
||||||
return { success: false, error: String(e) };
|
* feature comes back, re-introduce the table in `db/schema/` and replace
|
||||||
}
|
* this with a real Drizzle query.
|
||||||
}
|
*/
|
||||||
|
export async function getLotIdByNumber(_lotNumber: string): Promise<string | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { invalidateBrandFeatureCache, type BrandFeatureKey } from "@/lib/feature-flags";
|
import { withTenant } from "@/db/client";
|
||||||
|
import { brandSettings } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import {
|
||||||
|
invalidateBrandFeatureCache,
|
||||||
|
type BrandFeatureKey,
|
||||||
|
} from "@/lib/feature-flags";
|
||||||
|
|
||||||
export type ToggleFeatureResult =
|
export type ToggleFeatureResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle an add-on feature flag for a brand. The new schema stores feature
|
||||||
|
* flags as a JSONB blob in `brand_settings.feature_flags` — see
|
||||||
|
* `src/lib/feature-flags.ts` for the read path. The legacy RPC
|
||||||
|
* `set_brand_feature(p_brand_id, p_feature_key, p_enabled)` and the
|
||||||
|
* `brand_features` table it wrote to are gone.
|
||||||
|
*/
|
||||||
export async function toggleBrandFeature(
|
export async function toggleBrandFeature(
|
||||||
brandId: string,
|
brandId: string,
|
||||||
featureKey: BrandFeatureKey,
|
featureKey: BrandFeatureKey,
|
||||||
@@ -23,29 +35,37 @@ export async function toggleBrandFeature(
|
|||||||
return { success: false, error: "Not authorized for this brand" };
|
return { success: false, error: "Not authorized for this brand" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
await withTenant(brandId, async (db) => {
|
||||||
|
const existing = await db
|
||||||
|
.select({ featureFlags: brandSettings.featureFlags })
|
||||||
|
.from(brandSettings)
|
||||||
|
.where(eq(brandSettings.tenantId, brandId))
|
||||||
|
.limit(1);
|
||||||
|
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
|
||||||
|
const next = { ...current, [featureKey]: enabled };
|
||||||
|
if (existing.length === 0) {
|
||||||
|
// No row yet — bootstrap with the brand name from tenants.
|
||||||
|
await db
|
||||||
|
.insert(brandSettings)
|
||||||
|
.values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(brandSettings)
|
||||||
|
.set({ featureFlags: next, updatedAt: new Date() })
|
||||||
|
.where(eq(brandSettings.tenantId, brandId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(
|
invalidateBrandFeatureCache(brandId);
|
||||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
revalidatePath("/admin/settings/apps");
|
||||||
{
|
revalidatePath("/admin");
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: brandId,
|
|
||||||
p_feature_key: featureKey,
|
|
||||||
p_enabled: enabled,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
return { success: true };
|
||||||
return { success: false, error: "Failed to toggle feature" };
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : "Failed to toggle feature",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
invalidateBrandFeatureCache(brandId);
|
|
||||||
revalidatePath("/admin/settings/apps");
|
|
||||||
revalidatePath("/admin");
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|||||||
+57
-43
@@ -1,43 +1,29 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { pool } from "@/lib/db";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
|
||||||
|
|
||||||
export type UpdateShippingStatusResult =
|
export type UpdateShippingStatusResult =
|
||||||
| { success: true }
|
| { success: true }
|
||||||
| { success: false; error: string };
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
|
||||||
|
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
|
||||||
|
// the `shipping_status` column on `orders`, and the `update_shipping_order`
|
||||||
|
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are
|
||||||
|
// gone. The functions below stub to "not configured" so the admin
|
||||||
|
// shipping tab renders gracefully. Re-introduce shipping in
|
||||||
|
// `db/schema/` when the feature is reactivated.
|
||||||
|
|
||||||
export async function updateShippingStatus(
|
export async function updateShippingStatus(
|
||||||
orderId: string,
|
_orderId: string,
|
||||||
status: string,
|
_status: string,
|
||||||
trackingNumber?: string
|
_trackingNumber?: string
|
||||||
): Promise<UpdateShippingStatusResult> {
|
): Promise<UpdateShippingStatusResult> {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
return { success: false, error: "Shipping not configured" };
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/update_shipping_order`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_order_id: orderId,
|
|
||||||
p_shipping_status: status,
|
|
||||||
p_tracking_number: trackingNumber ?? null,
|
|
||||||
p_brand_id: await getActiveBrandId(adminUser),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to update shipping status" };
|
|
||||||
const data = await response.json();
|
|
||||||
if (!data.success) return { success: false, error: data.error ?? "Update failed" };
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetShippingOrdersResult = {
|
export type GetShippingOrdersResult = {
|
||||||
@@ -71,21 +57,49 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
// Read shipping-eligible orders from the new schema as a best-effort
|
||||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
// approximation. The legacy shape had `customer_*` columns and a join
|
||||||
|
// table; we fall back to `customers` for the name and `order_items`
|
||||||
const response = await fetch(
|
// for line-item info. `subtotal` (legacy) → `total_cents / 100`.
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_shipping_orders`,
|
const { rows } = await pool.query<{
|
||||||
{
|
id: string;
|
||||||
method: "POST",
|
customer_name: string | null;
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
customer_email: string | null;
|
||||||
body: JSON.stringify({
|
customer_phone: string | null;
|
||||||
p_brand_id: await getActiveBrandId(adminUser),
|
status: string;
|
||||||
}),
|
subtotal: number;
|
||||||
}
|
created_at: string;
|
||||||
|
tenant_id: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
o.id::text AS id,
|
||||||
|
c.name AS customer_name,
|
||||||
|
c.email AS customer_email,
|
||||||
|
c.phone AS customer_phone,
|
||||||
|
o.status,
|
||||||
|
o.total_cents::float / 100.0 AS subtotal,
|
||||||
|
o.placed_at::text AS created_at,
|
||||||
|
o.tenant_id::text AS tenant_id
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN customers c ON c.id = o.customer_id
|
||||||
|
WHERE o.fulfillment IN ('ship', 'mixed')
|
||||||
|
ORDER BY o.placed_at DESC
|
||||||
|
LIMIT 100`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) return { success: false, error: "Failed to fetch shipping orders" };
|
const orders: ShippingOrder[] = rows.map((r) => ({
|
||||||
const data = await response.json();
|
id: r.id,
|
||||||
return { success: true, orders: data };
|
customer_name: r.customer_name ?? "Unknown",
|
||||||
}
|
customer_email: r.customer_email,
|
||||||
|
customer_phone: r.customer_phone,
|
||||||
|
status: r.status,
|
||||||
|
subtotal: r.subtotal,
|
||||||
|
shipping_status: "pending",
|
||||||
|
tracking_number: null,
|
||||||
|
created_at: r.created_at,
|
||||||
|
brand_id: r.tenant_id,
|
||||||
|
order_items: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { success: true, orders };
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return apiError("Failed to redeem referral", 500);
|
return apiError("Failed to redeem referral", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id ?? "anonymous");
|
||||||
|
|
||||||
return apiResponse(referral, 201);
|
return apiResponse(referral, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user