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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
Reference in New Issue
Block a user