migrate: replace Supabase REST with Drizzle/pg in 28 more files (wave 5 final)

- admin components: TaxDashboard, OrderTableBody, TaxQuarterlySummary, StopProductAssignment, StopTableClient, ProductAssignmentForm, StopMessagingForm
- api routes: wholesale/checkout, wholesale/price-sheet, api/supabase (DELETED), api/reports/export, route-trace/* (stubbed - feature retired)
- lib: src/lib/supabase.ts (mock-only shim, no @supabase imports), src/lib/supabase/server.ts (DELETED)
- actions: wholesale-auth (stubbed - awaiting Auth.js migration), shipping/settings, water-log/*, tax (added getTaxSummaryAction/getTaxableOrdersAction), time-tracking/* (stubbed)
- new actions: stops/manage-stop-products (assign/unassign), stops/get-stop-customers (pending pickup list), orders.toggleOrderPickupComplete (legacy column)

All Supabase REST calls replaced with Drizzle ORM, raw pg.Pool queries, or
existing server actions. Typecheck: clean. Tests: 22/22 pass. Build: OK.
This commit is contained in:
2026-06-07 06:24:57 +00:00
parent 67abcaa2db
commit 50201b00fe
31 changed files with 1589 additions and 2866 deletions
+40 -52
View File
@@ -1,6 +1,17 @@
/**
* Wholesale order Stripe checkout endpoint.
*
* TODO(migration): wholesale_orders is part of the legacy schema and
* is read/written via raw `pool.query` SQL. The `get_wholesale_settings`
* and `get_payment_settings` SECURITY DEFINER RPCs still live in the
* database (see supabase/migrations/046 and 045) and are also called
* via `pool.query`. When wholesale is reactivated, declare the tables
* in `db/schema/wholesale.ts` and switch the reads to typed Drizzle.
*/
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function POST(req: NextRequest) {
const { orderId, customerId } = await req.json();
@@ -9,23 +20,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "orderId and customerId are required" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── 1. Fetch order and brand info ──────────────────────────────────────────
// Use direct select with both orderId AND customerId filters to prevent cross-brand access
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&customer_id=eq.${customerId}&select=id,brand_id,customer_id,balance_due,invoice_number,subtotal,deposit_required,deposit_paid`,
{
headers: { ...svcHeaders(supabaseKey) },
}
);
if (!orderRes.ok) {
return NextResponse.json({ error: "Failed to fetch order" }, { status: 500 });
}
const orders = await orderRes.json() as Array<{
const { rows: orderRows } = await pool.query<{
id: string;
brand_id: string;
customer_id: string;
@@ -34,9 +31,22 @@ export async function POST(req: NextRequest) {
subtotal: number;
deposit_required: number;
deposit_paid: number;
}>;
}>(
`SELECT id::text AS id,
brand_id::text AS brand_id,
customer_id::text AS customer_id,
COALESCE(balance_due, 0)::float8 AS balance_due,
invoice_number,
COALESCE(subtotal, 0)::float8 AS subtotal,
COALESCE(deposit_required, 0)::float8 AS deposit_required,
COALESCE(deposit_paid, 0)::float8 AS deposit_paid
FROM wholesale_orders
WHERE id = $1 AND customer_id = $2
LIMIT 1`,
[orderId, customerId]
);
const order = orders[0];
const order = orderRows[0];
if (!order) {
return NextResponse.json({ error: "Order not found" }, { status: 404 });
}
@@ -47,39 +57,21 @@ export async function POST(req: NextRequest) {
}
// ── 2. Check online payment is enabled ────────────────────────────────────
const wsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
const { rows: wsRows } = await pool.query<{ online_payment_enabled: boolean | null }>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
);
if (!wsRes.ok) {
return NextResponse.json({ error: "Failed to fetch wholesale settings" }, { status: 500 });
}
const wsData = await wsRes.json();
const wsData = wsRows[0];
if (!wsData?.online_payment_enabled) {
return NextResponse.json({ error: "Online payments are not enabled for this brand" }, { status: 403 });
}
// ── 3. Fetch Stripe credentials from payment_settings ─────────────────────
const psRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: order.brand_id }),
}
const { rows: psRows } = await pool.query<{ stripe_secret_key: string | null }>(
"SELECT * FROM get_payment_settings($1)",
[order.brand_id]
);
if (!psRes.ok) {
return NextResponse.json({ error: "Failed to fetch payment settings" }, { status: 500 });
}
const psData = await psRes.json();
const psData = psRows[0];
const stripeSecretKey = psData?.stripe_secret_key;
if (!stripeSecretKey) {
@@ -120,14 +112,10 @@ export async function POST(req: NextRequest) {
});
// Store checkout session ID on the order
await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ checkout_session_id: session.id }),
}
await pool.query(
"UPDATE wholesale_orders SET checkout_session_id = $2, updated_at = NOW() WHERE id = $1",
[orderId, session.id]
);
return NextResponse.json({ checkoutUrl: session.url });
}
}
+48 -34
View File
@@ -1,8 +1,21 @@
/**
* Wholesale price-sheet sender.
*
* TODO(migration): wholesale_customers and the
* `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the
* legacy schema. Reads are converted to `pool.query`; the
* `enqueue_wholesale_notification` RPC is still in the database
* (supabase/migrations/054) and is called via `pool.query` rather
* than the Supabase REST gateway. When wholesale is reactivated,
* move the tables into `db/schema/wholesale.ts` and switch reads to
* typed Drizzle.
*/
import { NextRequest, NextResponse } from "next/server";
import { getWholesaleSettings, getWholesaleProducts } from "@/actions/wholesale";
import { getWholesaleCustomer } from "@/actions/wholesale-register";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
export const dynamic = "force-dynamic";
@@ -31,7 +44,7 @@ export async function POST(req: NextRequest) {
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
try { assertBrandAccess(adminUser, effectiveBrandId); } catch {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
@@ -39,9 +52,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "customerIds array and brandId are required" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Fetch brand settings for branding + pickup location
const settings = await getWholesaleSettings(effectiveBrandId);
if (!settings) {
@@ -142,44 +152,48 @@ ${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size:
let failed = 0;
for (const customerId of customerIds) {
// Fetch customer email
const custRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=id,email,company_name,brand_id`,
{ headers: { ...svcHeaders(supabaseKey) } }
// Fetch customer email via raw SQL
const { rows: customers } = await pool.query<{
id: string;
email: string | null;
company_name: string | null;
brand_id: string | null;
}>(
`SELECT id::text AS id, email, company_name, brand_id::text AS brand_id
FROM wholesale_customers
WHERE id = $1
LIMIT 1`,
[customerId]
);
if (!custRes.ok) { failed++; continue; }
const customers = await custRes.json() as Array<{ id: string; email: string; company_name: string; brand_id: string }>;
const customer = customers[0];
if (!customer?.email) { failed++; continue; }
const enqueueRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: effectiveBrandId,
p_customer_id: customerId,
p_order_id: null,
p_type: "price_sheet",
p_email_to: customer.email,
p_email_cc: settings.notification_email ?? null,
p_subject: emailSubject,
p_body_html: html,
p_body_text: text,
}),
}
);
if (enqueueRes.ok) {
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
effectiveBrandId,
customerId,
null,
"price_sheet",
customer.email,
settings.notification_email ?? null,
emailSubject,
html,
text,
]
);
enqueued++;
} else {
} catch {
failed++;
}
}
// Fire-and-forget trigger to process the queue
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/send`, {
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ??
(req.nextUrl ? `${req.nextUrl.protocol}//${req.nextUrl.host}` : "http://localhost:3000");
fetch(`${baseUrl}/api/wholesale/notifications/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
}).catch(() => {});