migrate: replace Supabase REST with Drizzle/pg in billing + integrations + wholesale (wave 3)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user