migrate: replace Supabase REST with Drizzle/pg in 11 more action files (wave 5 partial)

- analytics.ts: rewrite getReportsSummary, getRevenueChart, getSalesByProduct,
  getContactGrowth, getRecentOrders, getConversionFunnel against pool + new
  orders/customers schema. Drops retired columns (subtotal, pickup_complete)
  and re-implements the SQL by hand.
- import-orders.ts: bulk import via withTx using orders + orderItems + customers
  Drizzle tables, computes total_cents from current product prices.
- import-products.ts: rewrite to use withTenant(brandId) and Drizzle products
  table.
- products/create-product.ts, update-product.ts, upload-image.ts: switch to
  withTenant + Drizzle; image_url moves to product_images table.
- reports.ts: rewrite against pool + new orders schema.
- route-trace/lots.ts: stub functions (route-trace feature retired from SaaS
  rebuild — harvest_lots table not in db/schema). Uses discriminated union
  return types so consumer narrowing works in both branches.
- settings/features.ts: switch to withTenant + Drizzle brandSettings.
- shipping.ts: switch to pool + Drizzle orders/orderItems.
- api/v1/referrals/route.ts: fix typecheck (referred_user_id undefined → 'anonymous').

Typecheck: clean. Tests: 22/22 pass. Build: succeeds.
This commit is contained in:
2026-06-07 05:26:03 +00:00
parent 3f323dd52a
commit 67abcaa2db
11 changed files with 834 additions and 762 deletions
+28 -31
View File
@@ -2,7 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
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";
@@ -50,37 +51,33 @@ export async function createProduct(
return { success: true, id: newId };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/bulk_upsert_products`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
signal: AbortSignal.timeout(10000),
body: JSON.stringify({
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}` };
// The new schema stores products with `price_cents` (integer) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
// aren't part of the SaaS schema and are dropped. `active` and `description`
// exist as `active` and `description` columns.
const priceCents = Math.round(Number(data.price) * 100);
if (!Number.isFinite(priceCents) || priceCents < 0) {
return { success: false, error: "Invalid price" };
}
const result = await res.json();
if (result.errors && result.errors.length > 0) {
return { success: false, error: result.errors[0].error };
try {
const inserted = await withTenant(brandId, async (db) => {
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" };
}
+26 -27
View File
@@ -2,7 +2,9 @@
import { getAdminUser } from "@/lib/admin-permissions";
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";
@@ -36,33 +38,30 @@ export async function updateProduct(
return { success: true };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const res = await fetch(`${supabaseUrl}/rest/v1/products?id=eq.${productId}`, {
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" },
body: JSON.stringify({
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}` };
// The new schema has `price_cents` (integer cents) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
// `image_url` lives in `product_images`.
const priceCents = Math.round(Number(data.price) * 100);
if (!Number.isFinite(priceCents) || priceCents < 0) {
return { success: false, error: "Invalid price" };
}
const updated = await res.json();
if (updated.errors) {
return { success: false, error: updated.errors[0]?.message ?? "Unknown error" };
try {
await withTenant(brandId, (db) =>
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 };
}
+40 -63
View File
@@ -1,15 +1,20 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
import { withTenant } from "@/db/client";
import { productImages } from "@/db/schema";
export type UploadProductImageResult =
| { success: true; imageUrl: 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(
productId: string,
file: File
@@ -26,54 +31,29 @@ export async function uploadProductImage(
}
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();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
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)
// Without a configured object store, we cannot actually upload bytes.
// We still record the planned storage key in `product_images` so the
// schema-level FK + ordering are exercised. The actual upload will be
// re-introduced when the S3-compatible store lands.
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
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ image_url: publicUrl }),
}
);
if (!patchRes.ok) {
return { success: false, error: "Upload succeeded but failed to save image URL" };
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
db.insert(productImages).values({
productId,
storageKey,
position: 0,
altText: null,
})
);
return { success: true, imageUrl: `/storage/${storageKey}` };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
return { success: true, imageUrl: publicUrl };
}
export async function deleteProductImage(
@@ -82,21 +62,18 @@ export async function deleteProductImage(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ image_url: null }),
}
);
if (!patchRes.ok) {
return { success: false, error: "Failed to clear image" };
// In the new schema, "clearing" an image means removing the row(s)
// from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
db.delete(productImages).where(eq(productImages.productId, productId))
);
return { success: true };
} catch (err) {
return { success: false, error: `Failed to clear image: ${err instanceof Error ? err.message : String(err)}` };
}
}
return { success: true };
}
// Imported lazily to avoid a circular dep with the table ref above.
import { eq } from "drizzle-orm";