916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
120 lines
4.8 KiB
TypeScript
120 lines
4.8 KiB
TypeScript
"use server";
|
|
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
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,
|
|
ordersToImport: Array<{
|
|
customer_name: string;
|
|
customer_email: string;
|
|
customer_phone: string;
|
|
stop_id: string;
|
|
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
|
|
}>
|
|
): Promise<ImportOrdersResult> {
|
|
const adminUser = await getAdminUser();
|
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
|
|
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
|
return { success: false, error: "Not authorized for this brand" };
|
|
}
|
|
|
|
const results = { imported: 0, errors: [] as { row: number; error: string }[] };
|
|
|
|
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;
|
|
}
|
|
|
|
// Fetch product prices for the brand.
|
|
const productRes = await pool.query<{ id: string; price_cents: number }>(
|
|
`SELECT id, price_cents FROM products WHERE brand_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 (brand_id, email).
|
|
const customerRes = await client.query<{ id: string }>(
|
|
`INSERT INTO customers (brand_id, name, email, phone, email_opt_in)
|
|
VALUES ($1, $2, $3, $4, true)
|
|
ON CONFLICT (brand_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 (brand_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 };
|
|
}
|