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
+19 -14
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
import { toggleOrderPickupComplete } from "@/actions/orders";
type Order = {
id: string;
@@ -18,21 +19,18 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
const [error, setError] = useState<string | null>(null);
async function togglePickup(orderId: string, current: boolean) {
setPickupToggles((prev) => ({ ...prev, [orderId]: !current }));
await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({ pickup_complete: !current }),
}
);
const next = !current;
setPickupToggles((prev) => ({ ...prev, [orderId]: next }));
setError(null);
const result = await toggleOrderPickupComplete({ orderId, pickupComplete: next });
if (!result.success) {
// Revert optimistic update on failure
setPickupToggles((prev) => ({ ...prev, [orderId]: current }));
setError(result.error);
}
}
const statusColors: Record<string, string> = {
@@ -45,6 +43,13 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
return (
<tbody className="divide-y divide-slate-200">
{error && (
<tr>
<td colSpan={6} className="px-3 py-2 text-sm text-red-400">
{error}
</td>
</tr>
)}
{orders.map((order) => (
<tr key={order.id} className="hover:bg-zinc-800">
<td className="px-3 py-2">
@@ -96,4 +101,4 @@ export default function OrderTableBody({ orders }: { orders: Order[] }) {
))}
</tbody>
);
}
}
+8 -37
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
type Product = {
id: string;
@@ -34,25 +35,10 @@ export default function ProductAssignmentForm({
setLoading(true);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
}),
}
);
const result = await assignProductToStop({ stopId, productId: selected });
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to assign product");
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
@@ -63,25 +49,10 @@ export default function ProductAssignmentForm({
}
async function handleRemove(productId: string) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
}),
}
);
const result = await unassignProductFromStop({ stopId, productId });
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to remove");
if (!result.success) {
setError(result.error);
return;
}
@@ -168,4 +139,4 @@ export default function ProductAssignmentForm({
)}
</div>
);
}
}
+28 -58
View File
@@ -1,18 +1,8 @@
"use client";
import { useState } from "react";
type Customer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
type StopMessagingFormProps = {
stopId: string;
};
import { getStopPendingCustomers, type StopCustomer } from "@/actions/stops/get-stop-customers";
import { sendStopBlast } from "@/actions/communications/stop-blast";
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
@@ -23,8 +13,14 @@ const quickMessages = [
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
const [customers, setCustomers] = useState<Customer[]>([]);
export default function StopMessagingForm({
stopId,
brandId,
}: {
stopId: string;
brandId: string;
}) {
const [customers, setCustomers] = useState<StopCustomer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
@@ -38,22 +34,16 @@ export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
setLoading(true);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
);
const result = await getStopPendingCustomers(stopId);
const data = await res.json();
if (!res.ok) {
setError(data.message);
} else {
setCustomers(data);
setLoaded(true);
if (!result.success) {
setError(result.error);
setLoading(false);
return;
}
setCustomers(result.customers);
setLoaded(true);
setLoading(false);
}
@@ -67,41 +57,21 @@ export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
setSending(true);
setError(null);
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
const blast = await sendStopBlast({
stopId,
brandId,
channel,
body: message,
audience: "pending",
});
// Call Supabase Edge Function to send messages
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/send-messages`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel,
message,
recipients: recipients.map((r) => ({
phone: r.customer_phone,
email: r.customer_email,
name: r.customer_name,
})),
}),
}
);
if (!res.ok) {
const err = await res.json();
setError(err.message ?? "Failed to send messages");
if (!blast.success) {
setError(blast.error);
setSending(false);
return;
}
setSent(recipients.length);
setSent(blast.messages_logged);
setSending(false);
}
@@ -253,4 +223,4 @@ export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
)}
</div>
);
}
}
+51 -94
View File
@@ -3,6 +3,7 @@
import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import { logAuditEvent } from "@/actions/audit";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
type Product = {
id: string;
@@ -89,65 +90,43 @@ export default function StopProductAssignment({
setPendingId(productId);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
p_caller_uid: callerUid,
}),
}
);
const data = await res.json();
const result = await assignProductToStop({ stopId, productId });
if (!res.ok || data.success === false) {
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
setError(`Failed to assign: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
setPendingId(null);
return;
}
// Optimistic insert: build the entry from the product we already have
// locally, keyed by the row id returned from the RPC. Use functional
// setState so concurrent calls compose correctly.
setProducts((prev) => {
if (prev.some((p) => p.product_id === productId)) return prev;
return [
...prev,
{
id: data.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
},
];
});
setPendingId(null);
logAuditEvent({
table_name: "product_stops",
record_id: data.id,
action: "INSERT",
old_data: null,
new_data: { stop_id: stopId, product_id: productId },
brand_id: null,
});
} catch {
setError("Network error while assigning product.");
if (!result.success) {
setError(`Failed to assign: ${result.error}`);
setPendingId(null);
return;
}
// Optimistic insert: build the entry from the product we already have
// locally, keyed by the row id returned from the RPC. Use functional
// setState so concurrent calls compose correctly.
setProducts((prev) => {
if (prev.some((p) => p.product_id === productId)) return prev;
return [
...prev,
{
id: result.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
},
];
});
setPendingId(null);
logAuditEvent({
table_name: "product_stops",
record_id: result.id,
action: "INSERT",
old_data: null,
new_data: { stop_id: stopId, product_id: productId },
brand_id: null,
});
}
async function remove(productId: string) {
@@ -158,47 +137,25 @@ export default function StopProductAssignment({
setRemovingId(productId);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
p_caller_uid: callerUid,
}),
}
);
const data = await res.json();
const result = await unassignProductFromStop({ stopId, productId });
if (!res.ok || data.success === false) {
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? "";
setError(`Failed to remove: ${errMsg}${errDetail ? " — " + errDetail : ""}`);
setRemovingId(null);
return;
}
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null);
logAuditEvent({
table_name: "product_stops",
record_id: entry.id,
action: "DELETE",
old_data: { stop_id: stopId, product_id: productId },
new_data: null,
brand_id: null,
});
} catch {
setError("Network error while removing product.");
if (!result.success) {
setError(`Failed to remove: ${result.error}`);
setRemovingId(null);
return;
}
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null);
logAuditEvent({
table_name: "product_stops",
record_id: entry.id,
action: "DELETE",
old_data: { stop_id: stopId, product_id: productId },
new_data: null,
brand_id: null,
});
}
function toggle(productId: string) {
+4 -15
View File
@@ -4,7 +4,7 @@
import React, { useState, useTransition, useEffect, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { publishStop, deleteStop } from "@/actions/stops";
import {
AdminSearchInput,
AdminFilterTabs,
@@ -449,26 +449,15 @@ function StopRowBase({
async function handleDelete() {
setDeletingId(stop.id);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_brand_id: stop.brand_id }),
}
);
const data = await res.json();
const result = await deleteStop(stop.id, stop.brand_id);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
} else {
onDeleteError(data.error ?? "Delete failed");
onDeleteError(result.error ?? "Delete failed");
}
}
+20 -64
View File
@@ -8,6 +8,11 @@ import {
type TaxOrderRow,
type TaxByStateRow,
} from "@/lib/reports-export";
import {
getTaxSummaryAction,
getTaxableOrdersAction,
type TaxSummaryData,
} from "@/actions/tax";
import { AdminButton, AdminFilterTabs } from "@/components/admin/design-system";
type DatePreset = "month" | "quarter" | "this_year" | "custom";
@@ -44,17 +49,7 @@ function quarterLabel(start: string, end: string): string {
return `Q${q} ${s.getFullYear()}`;
}
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
tax_by_state: Array<{
state: string;
total_tax: number;
gross_sales: number;
order_count: number;
}>;
};
type TaxSummaryDataLocal = TaxSummaryData;
function SummaryCard({
label,
@@ -143,7 +138,7 @@ export default function TaxDashboard({
const [customEnd, setCustomEnd] = useState("");
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
const [activeTab, setActiveTab] = useState("summary");
const [summary, setSummary] = useState<TaxSummaryData | null>(null);
const [summary, setSummary] = useState<TaxSummaryDataLocal | null>(null);
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -156,29 +151,13 @@ export default function TaxDashboard({
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setSummary({
total_tax_collected: Number(data?.total_tax_collected ?? 0),
total_gross_sales: Number(data?.total_gross_sales ?? 0),
order_count: Number(data?.order_count ?? 0),
tax_by_state: data?.tax_by_state ?? [],
const result = await getTaxSummaryAction({
brandId: selectedBrandId,
startDate: range.start,
endDate: range.end,
});
if (!result.success) throw new Error(result.error);
setSummary(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load tax summary");
} finally {
@@ -192,36 +171,13 @@ export default function TaxDashboard({
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_taxable_orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setOrders(
(data ?? []).map((row: Record<string, unknown>) => ({
order_id: String(row.order_id ?? ""),
date: String(row.date ?? ""),
customer_name: String(row.customer_name ?? ""),
city: String(row.city ?? ""),
state: String(row.state ?? ""),
taxable_amount: Number(row.taxable_amount ?? 0),
tax_amount: Number(row.tax_amount ?? 0),
tax_rate: Number(row.tax_rate ?? 0),
tax_location: String(row.tax_location ?? ""),
}))
);
const result = await getTaxableOrdersAction({
brandId: selectedBrandId,
startDate: range.start,
endDate: range.end,
});
if (!result.success) throw new Error(result.error);
setOrders(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load orders");
} finally {
+19 -23
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
import { getTaxSummaryAction } from "@/actions/tax";
type TaxSummaryData = {
total_tax_collected: number;
@@ -29,31 +30,26 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
useEffect(() => {
const range = quarterDateRange();
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
)
.then((r) => r.json())
.then((d) => {
let cancelled = false;
(async () => {
const result = await getTaxSummaryAction({
brandId,
startDate: range.start,
endDate: range.end,
});
if (cancelled) return;
if (result.success) {
setData({
total_tax_collected: Number(d?.total_tax_collected ?? 0),
total_gross_sales: Number(d?.total_gross_sales ?? 0),
order_count: Number(d?.order_count ?? 0),
total_tax_collected: result.data.total_tax_collected,
total_gross_sales: result.data.total_gross_sales,
order_count: result.data.order_count,
});
setLoading(false);
})
.catch(() => setLoading(false));
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [brandId]);
if (loading) {