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:
@@ -1,15 +1,26 @@
|
||||
"use server";
|
||||
|
||||
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 =
|
||||
| { success: true; imported: number; errors: { row: number; 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(
|
||||
brandId: string,
|
||||
orders: Array<{
|
||||
ordersToImport: Array<{
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
@@ -25,40 +36,84 @@ export async function importOrdersBatch(
|
||||
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 }[] };
|
||||
|
||||
for (const order of orders) {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
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,
|
||||
})),
|
||||
}),
|
||||
for (let i = 0; i < ordersToImport.length; i++) {
|
||||
const order = ordersToImport[i];
|
||||
try {
|
||||
// Compute total_cents server-side from current product prices.
|
||||
const productIds = order.items.map((it) => it.product_id);
|
||||
if (productIds.length === 0) {
|
||||
results.errors.push({ row: i, error: "Order has no items" });
|
||||
continue;
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
results.errors.push({ row: 0, error: `Failed to import order for ${order.customer_name}` });
|
||||
} else {
|
||||
// Fetch product prices for the brand.
|
||||
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
||||
`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++;
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user