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:
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user