migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)

This commit is contained in:
2026-06-07 03:25:22 +00:00
parent eb9621d238
commit 99a3d66636
26 changed files with 1216 additions and 1776 deletions
+15 -23
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
import { cookies } from "next/headers";
export async function GET(request: Request) {
@@ -97,30 +97,22 @@ export async function GET(request: Request) {
);
}
// Store token + location_id in payment_settings via upsert
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Store token + location_id in payment_settings via SECURITY DEFINER RPC
try {
const upsertResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
p_brand_id: brandId,
p_provider: "square",
p_square_access_token: accessToken,
p_square_location_id: locationId,
}),
}
await pool.query(
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
brandId,
"square",
null, // stripe_publishable_key
null, // stripe_secret_key
null, // stripe_user_id
accessToken,
locationId,
null, // square_sync_enabled (not changed here)
null, // square_inventory_mode (not changed here)
]
);
if (!upsertResponse.ok) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_failed", request.url)
);
}
} catch (err) {
return NextResponse.redirect(
new URL("/admin/settings/payments?error=square_token_save_error", request.url)
+15 -12
View File
@@ -3,7 +3,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { syncProductsToSquare, syncProductsFromSquare } from "@/actions/square-products";
import { syncOrdersFromSquare } from "@/actions/square-orders";
import { getPaymentSettings } from "@/actions/payments";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export async function POST(request: Request) {
const adminUser = await getAdminUser();
@@ -58,19 +58,22 @@ export async function POST(request: Request) {
}
// Update square_last_sync_at and square_last_sync_error
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const lastError = syncErrors.length > 0 ? syncErrors.slice(0, 5).join("; ") : null;
await fetch(`${supabaseUrl}/rest/v1/rpc/upsert_payment_settings`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_square_sync_enabled: true,
p_square_inventory_mode: settings?.square_inventory_mode ?? "none",
}),
});
await pool.query(
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
brandId,
null, // provider (not changed)
null, // stripe_publishable_key
null, // stripe_secret_key
null, // stripe_user_id
null, // square_access_token
null, // square_location_id
true, // square_sync_enabled
settings?.square_inventory_mode ?? "none",
]
);
return NextResponse.json({
success: syncErrors.length === 0,
+17 -37
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { syncProductsToSquare } from "@/actions/square-products";
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
export const dynamic = "force-dynamic";
@@ -18,24 +18,19 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "brand_id required" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const claimRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/claim_square_sync_queue`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", "Prefer": "return=representation" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!claimRes.ok) {
const errText = await claimRes.text();
// Claim a pending sync entry from the queue via SECURITY DEFINER RPC
let entries: Array<{ id: string; brand_id: string }> = [];
try {
const { rows } = await pool.query<{ id: string; brand_id: string }>(
"SELECT * FROM claim_square_sync_queue($1)",
[brandId]
);
entries = rows;
} catch (e: unknown) {
const errText = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: `Failed to claim queue: ${errText}` }, { status: 500 });
}
const entries = await claimRes.json();
if (!entries || entries.length === 0 || entries[0] === null) {
return NextResponse.json({ processed: 0, message: "No pending entries" });
}
@@ -46,29 +41,14 @@ export async function GET(request: Request) {
const newStatus = result.success ? "done" : "failed";
const lastError = result.errors.length > 0 ? result.errors[0] : null;
await fetch(
`${supabaseUrl}/rest/v1/rpc/update_square_sync_timestamp`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: entry.brand_id,
p_error: lastError,
}),
}
await pool.query(
"SELECT update_square_sync_timestamp($1, $2)",
[entry.brand_id, lastError]
);
await fetch(
`${supabaseUrl}/rest/v1/square_sync_queue?id=eq.${entry.id}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
status: newStatus,
processed_at: new Date().toISOString(),
last_error: lastError,
}),
}
await pool.query(
"UPDATE square_sync_queue SET status = $1, processed_at = $2, last_error = $3 WHERE id = $4",
[newStatus, new Date().toISOString(), lastError, entry.id]
);
return NextResponse.json({
@@ -2,7 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { getAdminUser } from "@/lib/admin-permissions";
import { formatDate } from "@/lib/format-date";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
type InvoiceSettings = {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
};
export async function GET(
req: NextRequest,
@@ -11,78 +34,40 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
// ── Token-based customer download ────────────────────────────────────────────
if (token) {
// Look up order by ID + token. Falls back to a direct select so the
// invoice_token is checked server-side (not exposed to the client).
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,invoice_token,brand_id,customer_id,created_at`,
{
headers: svcHeaders(supabaseKey),
}
// Look up order by ID + token. The invoice_token is checked server-side.
const tokenRes = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
[orderId, token]
);
if (!orderRes.ok) {
return new NextResponse("Not found", { status: 404 });
}
const orders = await orderRes.json();
if (!orders || orders.length === 0) {
if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
// Proceed to generate PDF — order token is verified
const brandId = orders[0].brand_id;
const brandId = tokenRes.rows[0].brand_id;
// Fetch full order data for PDF
const fullRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const fullRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
if (!fullRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
const allOrders = await fullRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const allOrders = fullRes.rows;
const order = allOrders.find(o => o.id === orderId);
if (!order) {
return new NextResponse("Not found", { status: 404 });
}
// Fetch brand-specific settings for invoice header
const settingsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const settingsRes = await pool.query<InvoiceSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[brandId]
);
const settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const settings = settingsRes.rows[0] ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
return new NextResponse(Buffer.from(pdfBytes), {
@@ -103,50 +88,23 @@ export async function GET(
}
// Fetch the order directly by ID with brand scoping
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: adminUser.brand_id ?? undefined }),
}
const orderRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[adminUser.brand_id ?? null]
);
if (!orderRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
const orders = await orderRes.json() as Array<{
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
}>;
const orders = orderRes.rows;
const order = orders.find(o => o.id === orderId);
if (!order) {
return new NextResponse("Order not found", { status: 404 });
}
const settingsRes = 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 settingsRes = await pool.query<InvoiceSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
);
const settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const settings = settingsRes.rows[0] ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
@@ -158,27 +116,7 @@ export async function GET(
});
}
async function buildInvoicePdf(
order: {
invoice_number: string | null;
company_name: string;
contact_name: string | null;
email: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
},
settings: {
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}
) {
async function buildInvoicePdf(order: OrderRow, settings: InvoiceSettings) {
const pdfDoc = await PDFDocument.create();
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
@@ -1,6 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
export async function GET(
req: NextRequest,
@@ -9,24 +25,17 @@ export async function GET(
const { orderId } = await params;
const token = req.nextUrl.searchParams.get("token");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
if (!supabaseUrl || !supabaseKey) {
if (!process.env.DATABASE_URL) {
return new NextResponse("Server misconfiguration", { status: 500 });
}
// ── Token-gated download (customer portal) ──────────────────────────────────
if (token) {
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&invoice_token=eq.${token}&select=id,invoice_number,brand_id`,
{ headers: svcHeaders(supabaseKey) }
const tokenRes = await pool.query<{ id: string }>(
"SELECT id FROM wholesale_orders WHERE id = $1 AND invoice_token = $2 LIMIT 1",
[orderId, token]
);
if (!orderRes.ok || orderRes.status === 204) {
return new NextResponse("Not found", { status: 404 });
}
const tokenOrders = await orderRes.json();
if (!tokenOrders || tokenOrders.length === 0) {
if (tokenRes.rows.length === 0) {
return new NextResponse("Not found", { status: 404 });
}
}
@@ -36,64 +45,38 @@ export async function GET(
let brandId = "00000000-0000-0000-0000-000000000000";
// First try direct order lookup to get brand_id
const directRes = await fetch(
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=id,brand_id,customer_id`,
{ headers: svcHeaders(supabaseKey) }
const directRes = await pool.query<{ brand_id: string }>(
"SELECT brand_id FROM wholesale_orders WHERE id = $1 LIMIT 1",
[orderId]
);
if (directRes.ok) {
const direct = await directRes.json();
if (direct && direct.length > 0) {
brandId = direct[0].brand_id;
}
if (directRes.rows.length > 0) {
brandId = directRes.rows[0].brand_id;
}
const orderRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
const orderRes = await pool.query<OrderRow>(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
);
if (!orderRes.ok) {
return new NextResponse("Failed to fetch order", { status: 500 });
}
type OrderRow = {
id: string;
invoice_number: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_paid: number;
balance_due: number;
items: Array<{ product_name: string; quantity: number; unit_price: number; line_total: number }>;
created_at: string;
brand_id: string;
};
const allOrders = await orderRes.json() as OrderRow[];
const allOrders = orderRes.rows;
const order = allOrders.find(o => o.id === orderId);
if (!order) {
return new NextResponse("Order not found", { status: 404 });
}
// Fetch settings for brand header
const settingsRes = 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 settingsRes = await pool.query<{
invoice_business_name?: string;
invoice_business_address?: string;
invoice_business_phone?: string;
invoice_business_email?: string;
invoice_business_website?: string;
}>(
"SELECT * FROM get_wholesale_settings($1)",
[order.brand_id]
);
const settingsData = await settingsRes.json();
const settings = settingsData ?? {};
const settings = settingsRes.rows[0] ?? {};
const pdfBytes = await buildInvoicePdf(order, settings);
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
// POST /api/wholesale/notifications/pickup-reminder
// Scans for fulfilled orders past their anticipated pickup date that haven't been
@@ -9,24 +9,8 @@ import { svcHeaders } from "@/lib/svc-headers";
export const dynamic = "force-dynamic";
export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Find orders: fulfilled status, past anticipated pickup date, not yet picked up
const ordersRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_overdue_orders`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({}),
}
);
if (!ordersRes.ok) {
return NextResponse.json({ error: "Failed to fetch overdue orders" }, { status: 500 });
}
const overdueOrders = await ordersRes.json() as Array<{
const ordersRes = await pool.query<{
id: string;
brand_id: string;
customer_id: string;
@@ -37,12 +21,16 @@ export async function POST() {
notification_email: string | null;
from_email: string | null;
invoice_business_email: string | null;
}>;
}>(
"SELECT * FROM get_wholesale_overdue_orders()"
);
if (overdueOrders.length === 0) {
if (ordersRes.rows.length === 0) {
return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 });
}
const overdueOrders = ordersRes.rows;
let enqueued = 0;
let skipped = 0;
@@ -55,33 +43,28 @@ export async function POST() {
const adminEmail =
order.notification_email ?? order.from_email ?? order.invoice_business_email;
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: order.brand_id,
p_customer_id: order.customer_id,
p_order_id: order.id,
p_type: "unclaimed_pickup",
p_email_to: order.customer_email,
p_email_cc: adminEmail,
p_subject: `Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
p_body_html: `
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
order.brand_id,
order.customer_id,
order.id,
"unclaimed_pickup",
order.customer_email,
adminEmail,
`Overdue Pickup — Order ${order.invoice_number ?? order.id.slice(0, 8)}`,
`
<h2>Order Overdue for Pickup</h2>
<p>Your order <strong>${order.invoice_number ?? order.id.slice(0, 8)}</strong> was due for pickup on <strong>${order.anticipated_pickup_date}</strong> and has not yet been picked up.</p>
${order.pickup_location ? `<p><strong>Pickup location:</strong> ${order.pickup_location}</p>` : ""}
<p>Please arrange pickup as soon as possible. Contact us if you have any questions.</p>
`,
p_body_text: `Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
}),
}
);
if (enqueueRes.ok) {
`Order ${order.invoice_number ?? order.id.slice(0, 8)} was due for pickup on ${order.anticipated_pickup_date} and has not been picked up.${order.pickup_location ? ` Pickup location: ${order.pickup_location}.` : ""} Please arrange pickup or contact us with any questions.`,
]
);
enqueued++;
} else {
} catch {
skipped++;
}
}
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale";
import { svcHeaders } from "@/lib/svc-headers";
import { markWholesaleNotificationSent } from "@/actions/wholesale";
import { pool } from "@/lib/db";
import type { NotificationRecipient } from "@/actions/wholesale";
// POST /api/wholesale/notifications/send
@@ -18,23 +18,7 @@ export async function POST() {
);
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const pendingRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_pending_notifications`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: null, p_limit: 20 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending notifications" }, { status: 500 });
}
const notifications = await pendingRes.json() as Array<{
const pendingRes = await pool.query<{
id: string;
type: string;
email_to: string;
@@ -46,12 +30,17 @@ export async function POST() {
order_id: string | null;
customer_id: string;
invoice_business_email: string | null;
}>;
}>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[null, 20]
);
if (notifications.length === 0) {
if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 });
}
const notifications = pendingRes.rows;
// Prefetch settings for each unique brand so we can resolve notification_recipients
const brandIds = [...new Set(notifications.map(n => n.brand_id))];
const brandSettingsMap: Record<string, {
@@ -62,13 +51,17 @@ export async function POST() {
}> = {};
await Promise.all(brandIds.map(async (bid) => {
const r = await fetch(`${supabaseUrl}/rest/v1/rpc/get_wholesale_settings`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: bid }),
});
if (r.ok) {
const data = await r.json();
const r = await pool.query<{
notification_recipients: NotificationRecipient[];
notification_email: string | null;
from_email: string | null;
invoice_business_email: string | null;
}>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
if (r.rows.length > 0) {
const data = r.rows[0];
brandSettingsMap[bid] = {
notification_recipients: data?.notification_recipients ?? [],
notification_email: data?.notification_email ?? null,
@@ -128,21 +121,20 @@ export async function POST() {
const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text);
// Log a separate notification entry for audit trail
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`, {
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: n.brand_id,
p_customer_id: n.customer_id,
p_order_id: n.order_id ?? null,
p_type: n.type,
p_email_to: recipient.email,
p_email_cc: null,
p_subject: n.subject,
p_body_html: n.body_html,
p_body_text: n.body_text,
}),
});
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
n.brand_id,
n.customer_id,
n.order_id ?? null,
n.type,
recipient.email,
null,
n.subject,
n.body_html,
n.body_text,
]
);
if (ok) sent++; else failed++;
}
@@ -179,8 +171,12 @@ async function sendOneEmail(
}
async function triggerPickupReminder() {
// Use the same Next.js server — the route is at /api/wholesale/notifications/pickup-reminder
// The pickup-reminder route is also exposed as a cron in vercel.json.
try {
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.VERCEL_URL;
if (!baseUrl) return;
await fetch(`${baseUrl}/api/wholesale/notifications/pickup-reminder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
@@ -1,36 +1,17 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
// POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
export async function POST() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (!supabaseUrl || !serviceRoleKey) {
if (!process.env.DATABASE_URL) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
// Fetch pending webhooks
const pendingRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_pending_webhooks`,
{
method: "POST",
headers: {
...svcHeaders(serviceRoleKey),
"Content-Type": "application/json",
},
body: JSON.stringify({ p_limit: 10 }),
}
);
if (!pendingRes.ok) {
return NextResponse.json({ error: "Failed to fetch pending webhooks" }, { status: 500 });
}
const pending = await pendingRes.json() as Array<{
const pendingRes = await pool.query<{
id: string;
brand_id: string;
event_type: string;
@@ -39,12 +20,16 @@ export async function POST() {
attempts: number;
url: string;
secret: string;
}>;
}>(
"SELECT * FROM get_pending_webhooks($1)",
[10]
);
if (!pending || pending.length === 0) {
if (pendingRes.rows.length === 0) {
return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 });
}
const pending = pendingRes.rows;
let dispatched = 0;
for (const webhook of pending) {
@@ -71,40 +56,40 @@ export async function POST() {
const responseText = await res.text().catch(() => "");
if (res.ok) {
await markSent(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
await markSent(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
dispatched++;
} else {
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
await markFailed(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : "Network error";
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, msg);
await markFailed(webhook.id, msg);
}
}
return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched });
}
async function markSent(logId: string, key: string, url: string, response: string) {
await fetch(
`${url}/rest/v1/rpc/mark_webhook_sent`,
{
method: "POST",
headers: { ...svcHeaders(key), "Content-Type": "application/json" },
body: JSON.stringify({ p_log_id: logId, p_response: response }),
}
);
async function markSent(logId: string, response: string) {
try {
await pool.query(
"SELECT mark_webhook_sent($1, $2)",
[logId, response]
);
} catch {
// best-effort
}
}
async function markFailed(logId: string, key: string, url: string, response: string) {
await fetch(
`${url}/rest/v1/rpc/mark_webhook_failed`,
{
method: "POST",
headers: { ...svcHeaders(key), "Content-Type": "application/json" },
body: JSON.stringify({ p_log_id: logId, p_response: response }),
}
);
async function markFailed(logId: string, response: string) {
try {
await pool.query(
"SELECT mark_webhook_failed($1, $2)",
[logId, response]
);
} catch {
// best-effort
}
}
export async function GET() {