Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { orderId, customerId } = await req.json();
|
||||
|
||||
if (!orderId || !customerId) {
|
||||
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<{
|
||||
id: string;
|
||||
brand_id: string;
|
||||
customer_id: string;
|
||||
balance_due: number;
|
||||
invoice_number: string | null;
|
||||
subtotal: number;
|
||||
deposit_required: number;
|
||||
deposit_paid: number;
|
||||
}>;
|
||||
|
||||
const order = orders[0];
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: "Order not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { balance_due } = order;
|
||||
if (balance_due <= 0) {
|
||||
return NextResponse.json({ error: "No balance due on this order" }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── 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 }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!wsRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch wholesale settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
const wsData = await wsRes.json();
|
||||
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 }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!psRes.ok) {
|
||||
return NextResponse.json({ error: "Failed to fetch payment settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
const psData = await psRes.json();
|
||||
const stripeSecretKey = psData?.stripe_secret_key;
|
||||
|
||||
if (!stripeSecretKey) {
|
||||
return NextResponse.json({ error: "Stripe is not configured for this brand" }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── 4. Create Stripe Checkout Session ──────────────────────────────────────
|
||||
const stripe = new Stripe(stripeSecretKey);
|
||||
|
||||
const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ["card"],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: `Wholesale Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
description:
|
||||
balance_due >= order.deposit_required
|
||||
? "Deposit payment"
|
||||
: "Balance payment",
|
||||
},
|
||||
unit_amount: Math.round(balance_due * 100), // convert to cents
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: "payment",
|
||||
success_url: `${origin}/wholesale/payment/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/wholesale/portal?tab=orders`,
|
||||
metadata: {
|
||||
order_id: orderId,
|
||||
customer_id: customerId,
|
||||
brand_id: order.brand_id,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 }),
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ checkoutUrl: session.url });
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ orderId: string }> }
|
||||
) {
|
||||
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),
|
||||
}
|
||||
);
|
||||
|
||||
if (!orderRes.ok) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const orders = await orderRes.json();
|
||||
if (!orders || orders.length === 0) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Proceed to generate PDF — order token is verified
|
||||
const brandId = orders[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 }),
|
||||
}
|
||||
);
|
||||
|
||||
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 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 settingsData = await settingsRes.json();
|
||||
const settings = settingsData ?? {};
|
||||
|
||||
const pdfBytes = await buildInvoicePdf(order, settings);
|
||||
return new NextResponse(Buffer.from(pdfBytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Admin / no-token path — requires admin auth + brand scoping ─────────────
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
if (!adminUser.can_manage_orders && !adminUser.can_manage_reports) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// 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 }),
|
||||
}
|
||||
);
|
||||
|
||||
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 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 settingsData = await settingsRes.json();
|
||||
const settings = settingsData ?? {};
|
||||
|
||||
const pdfBytes = await buildInvoicePdf(order, settings);
|
||||
|
||||
return new NextResponse(Buffer.from(pdfBytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const page = pdfDoc.addPage([612, 792]);
|
||||
const { width, height } = page.getSize();
|
||||
const margin = 50;
|
||||
let y = height - margin;
|
||||
|
||||
const text = (font: typeof helvetica, size: number, content: string, x: number, lineHeight?: number) => {
|
||||
page.drawText(content, { x, y, font, size });
|
||||
y -= (lineHeight ?? size * 1.4);
|
||||
};
|
||||
|
||||
const drawLine = () => {
|
||||
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
|
||||
y -= 10;
|
||||
};
|
||||
|
||||
const businessName = settings.invoice_business_name || "Wholesale Portal";
|
||||
const businessAddress = settings.invoice_business_address;
|
||||
const businessPhone = settings.invoice_business_phone;
|
||||
const businessEmail = settings.invoice_business_email;
|
||||
const businessWebsite = settings.invoice_business_website;
|
||||
|
||||
// Header
|
||||
text(helveticaBold, 18, businessName, margin);
|
||||
y -= 8;
|
||||
|
||||
if (businessAddress) {
|
||||
const lines = businessAddress.split("\n");
|
||||
for (const line of lines) {
|
||||
text(helvetica, 9, line, margin);
|
||||
}
|
||||
}
|
||||
if (businessPhone) text(helvetica, 9, businessPhone, margin);
|
||||
if (businessEmail) text(helvetica, 9, businessEmail, margin);
|
||||
if (businessWebsite) text(helvetica, 9, businessWebsite, margin);
|
||||
|
||||
y -= 4;
|
||||
text(helvetica, 10, `Invoice: ${order.invoice_number ?? ""}`, margin);
|
||||
text(helvetica, 10, `Date: ${formatDate(new Date(order.created_at))}`, margin);
|
||||
if (order.anticipated_pickup_date) {
|
||||
text(helvetica, 10, `Pickup: ${order.anticipated_pickup_date}`, margin);
|
||||
}
|
||||
|
||||
y -= 10;
|
||||
drawLine();
|
||||
|
||||
// Bill To
|
||||
text(helveticaBold, 11, "Bill To:", margin);
|
||||
text(helvetica, 10, order.company_name, margin);
|
||||
if (order.contact_name) text(helvetica, 10, order.contact_name, margin);
|
||||
text(helvetica, 10, order.email, margin);
|
||||
|
||||
y -= 10;
|
||||
drawLine();
|
||||
|
||||
// Column headers
|
||||
const colX = { product: margin, qty: 340, price: 400, total: 480 };
|
||||
text(helveticaBold, 9, "Product", colX.product);
|
||||
text(helveticaBold, 9, "Qty", colX.qty);
|
||||
text(helveticaBold, 9, "Price", colX.price);
|
||||
text(helveticaBold, 9, "Total", colX.total);
|
||||
y += 4;
|
||||
drawLine();
|
||||
|
||||
// Line items
|
||||
for (const item of order.items) {
|
||||
const lineTotal = typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total) || 0;
|
||||
const qty = typeof item.quantity === "number" ? item.quantity : parseFloat(item.quantity) || 0;
|
||||
const unitPrice = typeof item.unit_price === "number" ? item.unit_price : parseFloat(item.unit_price) || 0;
|
||||
|
||||
text(helvetica, 9, (item.product_name ?? "Product").slice(0, 40), colX.product);
|
||||
text(helvetica, 9, qty.toString(), colX.qty);
|
||||
text(helvetica, 9, `$${unitPrice.toFixed(2)}`, colX.price);
|
||||
text(helvetica, 9, `$${lineTotal.toFixed(2)}`, colX.total);
|
||||
y -= 18;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine();
|
||||
|
||||
// Totals
|
||||
const subtotal = typeof order.subtotal === "number" ? order.subtotal : parseFloat(order.subtotal) || 0;
|
||||
const depositPaid = typeof order.deposit_paid === "number" ? order.deposit_paid : parseFloat(order.deposit_paid) || 0;
|
||||
const balanceDue = typeof order.balance_due === "number" ? order.balance_due : parseFloat(order.balance_due) || 0;
|
||||
|
||||
y -= 8;
|
||||
text(helvetica, 10, "Subtotal:", 340);
|
||||
text(helvetica, 10, `$${subtotal.toFixed(2)}`, 480);
|
||||
y -= 16;
|
||||
text(helvetica, 10, "Deposit Paid:", 340);
|
||||
text(helvetica, 10, `$${depositPaid.toFixed(2)}`, 480);
|
||||
y -= 16;
|
||||
|
||||
page.drawText("Balance Due:", { x: 340, y, font: helveticaBold, size: 11 });
|
||||
page.drawText(`$${balanceDue.toFixed(2)}`, { x: 480, y, font: helveticaBold, size: 11 });
|
||||
y -= 30;
|
||||
|
||||
drawLine();
|
||||
|
||||
// Footer
|
||||
text(helvetica, 8, "Thank you for your wholesale order.", margin);
|
||||
y -= 6;
|
||||
text(helvetica, 8, "Questions? Contact us at the email on file.", margin);
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ orderId: string }> }
|
||||
) {
|
||||
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) {
|
||||
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) }
|
||||
);
|
||||
if (!orderRes.ok || orderRes.status === 204) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const tokenOrders = await orderRes.json();
|
||||
if (!tokenOrders || tokenOrders.length === 0) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch full order + settings ────────────────────────────────────────────
|
||||
// Use the RPC for brand-scoped fetch (works for both token + admin paths)
|
||||
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) }
|
||||
);
|
||||
if (directRes.ok) {
|
||||
const direct = await directRes.json();
|
||||
if (direct && direct.length > 0) {
|
||||
brandId = direct[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 }),
|
||||
}
|
||||
);
|
||||
|
||||
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 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 settingsData = await settingsRes.json();
|
||||
const settings = settingsData ?? {};
|
||||
|
||||
const pdfBytes = await buildInvoicePdf(order, settings);
|
||||
|
||||
return new NextResponse(Buffer.from(pdfBytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="invoice-${order.invoice_number ?? orderId.slice(0, 8)}.pdf"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function buildInvoicePdf(
|
||||
order: {
|
||||
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;
|
||||
},
|
||||
settings: {
|
||||
invoice_business_name?: string;
|
||||
invoice_business_address?: string;
|
||||
invoice_business_phone?: string;
|
||||
invoice_business_email?: string;
|
||||
invoice_business_website?: string;
|
||||
}
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const page = pdfDoc.addPage([612, 792]);
|
||||
const { width } = page.getSize();
|
||||
const margin = 50;
|
||||
const rightEdge = width - margin;
|
||||
let y = page.getSize().height - margin;
|
||||
|
||||
const col = {
|
||||
product: margin,
|
||||
qty: 370,
|
||||
price: 440,
|
||||
total: 510,
|
||||
};
|
||||
|
||||
function drawText(
|
||||
text: string,
|
||||
x: number,
|
||||
font: typeof helveticaBold,
|
||||
size: number,
|
||||
color = rgb(0, 0, 0)
|
||||
) {
|
||||
page.drawText(text, { x, y, font, size, color });
|
||||
y -= size * 1.5;
|
||||
}
|
||||
|
||||
function measureWidth(text: string, font: typeof helvetica, size: number) {
|
||||
return font.widthOfTextAtSize(text, size);
|
||||
}
|
||||
|
||||
function rightAlign(text: string, font: typeof helvetica, size: number, atX: number) {
|
||||
const w = measureWidth(text, font, size);
|
||||
return atX - w;
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
const businessName = settings.invoice_business_name || "Wholesale Portal";
|
||||
drawText(businessName, margin, helveticaBold, 18);
|
||||
|
||||
y -= 4;
|
||||
if (settings.invoice_business_address) {
|
||||
for (const line of settings.invoice_business_address.split("\n")) {
|
||||
drawText(line, margin, helvetica, 9);
|
||||
}
|
||||
}
|
||||
if (settings.invoice_business_phone) {
|
||||
drawText(settings.invoice_business_phone, margin, helvetica, 9);
|
||||
}
|
||||
if (settings.invoice_business_email) {
|
||||
drawText(settings.invoice_business_email, margin, helvetica, 9);
|
||||
}
|
||||
if (settings.invoice_business_website) {
|
||||
drawText(settings.invoice_business_website, margin, helvetica, 9);
|
||||
}
|
||||
|
||||
y -= 12;
|
||||
|
||||
// ── Invoice meta block (right side) ─────────────────────────────────────
|
||||
const invoiceNum = order.invoice_number ?? "—";
|
||||
const invoiceDate = new Date(order.created_at).toLocaleDateString("en-US", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
|
||||
const rightX = 370;
|
||||
let ry = page.getSize().height - margin;
|
||||
|
||||
page.drawText(`Invoice:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
|
||||
page.drawText(invoiceNum, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
|
||||
ry -= 15;
|
||||
page.drawText(`Date:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
|
||||
page.drawText(invoiceDate, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
|
||||
ry -= 15;
|
||||
if (order.anticipated_pickup_date) {
|
||||
page.drawText(`Pickup:`, { x: rightX, y: ry, font: helveticaBold, size: 10 });
|
||||
page.drawText(order.anticipated_pickup_date, { x: rightX + 65, y: ry, font: helvetica, size: 10 });
|
||||
}
|
||||
|
||||
y = ry - 10;
|
||||
|
||||
// ── Divider ──────────────────────────────────────────────────────────────
|
||||
page.drawLine({
|
||||
start: { x: margin, y }, end: { x: rightEdge, y },
|
||||
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
|
||||
});
|
||||
y -= 20;
|
||||
|
||||
// ── Bill To ──────────────────────────────────────────────────────────────
|
||||
drawText("Bill To:", margin, helveticaBold, 10);
|
||||
y += 4;
|
||||
drawText(order.company_name, margin, helvetica, 10);
|
||||
if (order.contact_name) {
|
||||
drawText(order.contact_name, margin, helvetica, 10);
|
||||
}
|
||||
drawText(order.customer_email, margin, helvetica, 10);
|
||||
if (order.customer_phone) {
|
||||
drawText(order.customer_phone, margin, helvetica, 10);
|
||||
}
|
||||
|
||||
y -= 15;
|
||||
|
||||
// ── Column headers ────────────────────────────────────────────────────────
|
||||
page.drawLine({
|
||||
start: { x: margin, y }, end: { x: rightEdge, y },
|
||||
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
|
||||
});
|
||||
y -= 4;
|
||||
page.drawText("Product", { x: col.product, y, font: helveticaBold, size: 9 });
|
||||
page.drawText("Qty", { x: col.qty, y, font: helveticaBold, size: 9 });
|
||||
page.drawText("Unit Price", { x: col.price, y, font: helveticaBold, size: 9 });
|
||||
page.drawText("Total", { x: col.total, y, font: helveticaBold, size: 9 });
|
||||
y -= 18;
|
||||
page.drawLine({
|
||||
start: { x: margin, y }, end: { x: rightEdge, y },
|
||||
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
|
||||
});
|
||||
y -= 8;
|
||||
|
||||
// ── Line items ───────────────────────────────────────────────────────────
|
||||
for (const item of order.items) {
|
||||
const lineTotal = Number(item.line_total) || 0;
|
||||
const qty = Number(item.quantity) || 0;
|
||||
const unitPrice = Number(item.unit_price) || 0;
|
||||
const name = (item.product_name ?? "Product").substring(0, 42);
|
||||
|
||||
page.drawText(name, { x: col.product, y, font: helvetica, size: 9 });
|
||||
page.drawText(qty.toString(), { x: col.qty, y, font: helvetica, size: 9 });
|
||||
page.drawText(`$${unitPrice.toFixed(2)}`, { x: col.price, y, font: helvetica, size: 9 });
|
||||
page.drawText(`$${lineTotal.toFixed(2)}`, { x: col.total, y, font: helvetica, size: 9 });
|
||||
y -= 16;
|
||||
}
|
||||
|
||||
y -= 8;
|
||||
page.drawLine({
|
||||
start: { x: margin, y }, end: { x: rightEdge, y },
|
||||
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
|
||||
});
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────────────
|
||||
const subtotal = Number(order.subtotal) || 0;
|
||||
const depositPaid = Number(order.deposit_paid) || 0;
|
||||
const balanceDue = Number(order.balance_due) || 0;
|
||||
|
||||
y -= 16;
|
||||
const labelX = 370;
|
||||
const valueX = 510;
|
||||
|
||||
function drawRightLabel(label: string, value: string, font: typeof helvetica, size: number, isBold = false) {
|
||||
page.drawText(label, { x: labelX, y, font: isBold ? helveticaBold : font, size });
|
||||
const vw = measureWidth(value, font, size);
|
||||
page.drawText(value, { x: valueX - vw, y, font: isBold ? helveticaBold : font, size });
|
||||
y -= size * 1.8;
|
||||
}
|
||||
|
||||
drawRightLabel("Subtotal:", `$${subtotal.toFixed(2)}`, helvetica, 10);
|
||||
drawRightLabel("Deposit Paid:", `$${depositPaid.toFixed(2)}`, helvetica, 10);
|
||||
y -= 4;
|
||||
drawRightLabel("Balance Due:", `$${balanceDue.toFixed(2)}`, helveticaBold, 11, true);
|
||||
|
||||
y -= 15;
|
||||
page.drawLine({
|
||||
start: { x: margin, y }, end: { x: rightEdge, y },
|
||||
thickness: 0.5, color: rgb(0.85, 0.85, 0.85),
|
||||
});
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────────
|
||||
y -= 20;
|
||||
const footerLines = [
|
||||
"Thank you for your wholesale order.",
|
||||
"Questions? Contact us at the email on file.",
|
||||
`Generated ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}`,
|
||||
];
|
||||
for (const line of footerLines) {
|
||||
page.drawText(line, { x: margin, y, font: helvetica, size: 8, color: rgb(0.45, 0.45, 0.45) });
|
||||
y -= 12;
|
||||
}
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!adminUser.can_manage_orders) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { orders, brandId } = body as {
|
||||
orders: Array<{
|
||||
id: string;
|
||||
invoice_number: string | null;
|
||||
company_name: string;
|
||||
contact_name: string | null;
|
||||
anticipated_pickup_date: string | null;
|
||||
items: Array<{ product_name: string; quantity: number; unit_type: string; line_total: number }>;
|
||||
subtotal: number;
|
||||
}>;
|
||||
brandId?: string;
|
||||
};
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
|
||||
if (adminUser.role === "brand_admin" && brandId && adminUser.brand_id !== brandId) {
|
||||
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
return NextResponse.json({ error: "No orders provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build HTML manifest for print
|
||||
const rows = orders.map((o, i) => {
|
||||
const itemsHtml = o.items.map(item => `
|
||||
<tr>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${i + 1}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.invoice_number ?? o.id.slice(0, 8)}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.company_name}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${item.product_name ?? ""}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;text-align:right;">${item.quantity} ${item.unit_type ?? ""}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;text-align:right;">$${(typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total as string) || 0).toFixed(2)}</td>
|
||||
<td style="padding:4px;border-bottom:1px solid #e5e7eb;">${o.anticipated_pickup_date ?? "—"}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
return itemsHtml;
|
||||
}).join("");
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Load Manifest</title>
|
||||
<style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; margin: 24px; color: #1f2937; }
|
||||
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||
.meta { color: #6b7280; font-size: 12px; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th { background: #f3f4f6; padding: 6px 4px; text-align: left; border: 1px solid #d1d5db; }
|
||||
td { padding: 4px; }
|
||||
.footer { margin-top: 24px; font-size: 11px; color: #9ca3af; }
|
||||
@media print { body { margin: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Load Manifest</h1>
|
||||
<p class="meta">Generated: ${new Date().toLocaleString()} | ${orders.length} order(s)</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Invoice</th>
|
||||
<th>Customer</th>
|
||||
<th>Product</th>
|
||||
<th style="text-align:right;">Qty</th>
|
||||
<th style="text-align:right;">Total</th>
|
||||
<th>Pickup Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${orders.map((o, i) => {
|
||||
const itemsHtml = o.items.map(item => `
|
||||
<tr>
|
||||
<td style="padding:4px;">${i + 1}</td>
|
||||
<td style="padding:4px;">${o.invoice_number ?? o.id.slice(0, 8)}</td>
|
||||
<td style="padding:4px;">${o.company_name}</td>
|
||||
<td style="padding:4px;">${item.product_name ?? ""}</td>
|
||||
<td style="padding:4px;text-align:right;">${item.quantity} ${item.unit_type ?? ""}</td>
|
||||
<td style="padding:4px;text-align:right;">$${(typeof item.line_total === "number" ? item.line_total : parseFloat(item.line_total as string) || 0).toFixed(2)}</td>
|
||||
<td style="padding:4px;">${o.anticipated_pickup_date ?? "—"}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
return itemsHtml;
|
||||
}).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
Route Commerce Wholesale Portal — Load Manifest
|
||||
</div>
|
||||
<script>window.print();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"Content-Disposition": "inline; filename=manifest.html",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// POST /api/wholesale/notifications/pickup-reminder
|
||||
// Scans for fulfilled orders past their anticipated pickup date that haven't been
|
||||
// picked up, and enqueues unclaimed_pickup notifications for each.
|
||||
// Designed to be called daily via cron or from the notification send loop.
|
||||
|
||||
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<{
|
||||
id: string;
|
||||
brand_id: string;
|
||||
customer_id: string;
|
||||
invoice_number: string | null;
|
||||
anticipated_pickup_date: string;
|
||||
pickup_location: string | null;
|
||||
customer_email: string;
|
||||
notification_email: string | null;
|
||||
from_email: string | null;
|
||||
invoice_business_email: string | null;
|
||||
}>;
|
||||
|
||||
if (overdueOrders.length === 0) {
|
||||
return NextResponse.json({ message: "No overdue orders found.", enqueued: 0 });
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const order of overdueOrders) {
|
||||
if (!order.customer_email) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
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: `
|
||||
<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) {
|
||||
enqueued++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Pickup reminder scan complete.`,
|
||||
overdue_count: overdueOrders.length,
|
||||
enqueued,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWholesalePendingNotifications, markWholesaleNotificationSent } from "@/actions/wholesale";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import type { NotificationRecipient } from "@/actions/wholesale";
|
||||
|
||||
// POST /api/wholesale/notifications/send
|
||||
// Processes pending customer notifications and dispatches copies to all active
|
||||
// notification_recipients for each brand.
|
||||
// Falls back to notification_email / from_email if no active recipients are configured.
|
||||
|
||||
export async function POST() {
|
||||
const resendApiKey = process.env.RESEND_API_KEY;
|
||||
|
||||
if (!resendApiKey) {
|
||||
return NextResponse.json(
|
||||
{ message: "RESEND_API_KEY not configured — notifications are queued but not sent.", queued: 0, sent: 0 },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
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<{
|
||||
id: string;
|
||||
type: string;
|
||||
email_to: string;
|
||||
email_cc: string | null;
|
||||
subject: string;
|
||||
body_html: string | null;
|
||||
body_text: string | null;
|
||||
brand_id: string;
|
||||
order_id: string | null;
|
||||
customer_id: string;
|
||||
invoice_business_email: string | null;
|
||||
}>;
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return NextResponse.json({ message: "No pending notifications.", queued: 0, sent: 0 });
|
||||
}
|
||||
|
||||
// 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, {
|
||||
notification_recipients: NotificationRecipient[];
|
||||
notification_email: string | null;
|
||||
from_email: string | null;
|
||||
invoice_business_email: string | null;
|
||||
}> = {};
|
||||
|
||||
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();
|
||||
brandSettingsMap[bid] = {
|
||||
notification_recipients: data?.notification_recipients ?? [],
|
||||
notification_email: data?.notification_email ?? null,
|
||||
from_email: data?.from_email ?? null,
|
||||
invoice_business_email: data?.invoice_business_email ?? null,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const n of notifications) {
|
||||
if (!n.email_to) {
|
||||
await markWholesaleNotificationSent(n.id, "No email_to address");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = brandSettingsMap[n.brand_id];
|
||||
const activeRecipients = (settings?.notification_recipients ?? [])
|
||||
.filter(r => r.active);
|
||||
|
||||
// ── Fallback admin email if no active recipients ───────────────────────────
|
||||
const fallbackAdminEmail =
|
||||
settings?.notification_email
|
||||
?? settings?.from_email
|
||||
?? settings?.invoice_business_email
|
||||
?? null;
|
||||
|
||||
const fromEmail = n.invoice_business_email ?? "wholesale@routecommerce.com";
|
||||
|
||||
// ── Send to customer ───────────────────────────────────────────────────────
|
||||
const customerOk = await sendOneEmail(resendApiKey, fromEmail, n.email_to, n.email_cc ?? undefined, n.subject, n.body_html, n.body_text);
|
||||
if (customerOk) {
|
||||
await markWholesaleNotificationSent(n.id);
|
||||
sent++;
|
||||
} else {
|
||||
await markWholesaleNotificationSent(n.id, "Resend error");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Send to notification recipients ───────────────────────────────────────
|
||||
const recipients = activeRecipients.length > 0
|
||||
? activeRecipients
|
||||
: fallbackAdminEmail ? [{ email: fallbackAdminEmail, name: undefined, active: true }] : [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
// Skip the customer email if they happen to also be a recipient (dedup)
|
||||
if (recipient.email === n.email_to) continue;
|
||||
|
||||
const toAddress = recipient.name
|
||||
? `"${recipient.name}" <${recipient.email}>`
|
||||
: recipient.email;
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ok) sent++; else failed++;
|
||||
}
|
||||
}
|
||||
|
||||
await triggerPickupReminder();
|
||||
|
||||
return NextResponse.json({
|
||||
message: `Processed ${notifications.length} notification(s).`,
|
||||
sent,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendOneEmail(
|
||||
apiKey: string,
|
||||
from: string,
|
||||
to: string,
|
||||
cc: string | undefined,
|
||||
subject: string,
|
||||
html: string | null,
|
||||
text: string | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from, to, cc: cc ?? undefined, subject, html: html ?? undefined, text: text ?? undefined }),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerPickupReminder() {
|
||||
try {
|
||||
await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/pickup-reminder`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const resendApiKey = process.env.RESEND_API_KEY;
|
||||
return NextResponse.json({
|
||||
email_provider: resendApiKey ? "resend" : "not_configured",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/wholesale/price-sheet
|
||||
// Generates an HTML price sheet and enqueues it as a price_sheet notification
|
||||
// for one or more wholesale customers.
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!adminUser.can_manage_settings && !adminUser.can_manage_products) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { customerIds, brandId, subject, customNote } = body as {
|
||||
customerIds: string[];
|
||||
brandId: string;
|
||||
subject?: string;
|
||||
customNote?: string;
|
||||
};
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
|
||||
if (!effectiveBrandId) {
|
||||
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
|
||||
}
|
||||
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
|
||||
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!customerIds?.length || !effectiveBrandId) {
|
||||
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) {
|
||||
return NextResponse.json({ error: "Brand settings not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch products for this brand
|
||||
const products = await getWholesaleProducts(effectiveBrandId);
|
||||
if (products.length === 0) {
|
||||
return NextResponse.json({ error: "No products found for this brand" }, { status: 404 });
|
||||
}
|
||||
|
||||
const brandName = settings.invoice_business_name ?? "Wholesale";
|
||||
const pickupLocation = settings.pickup_location;
|
||||
const generatedDate = new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric", month: "long", day: "numeric"
|
||||
});
|
||||
const emailSubject = subject?.trim() || `${brandName} Wholesale Price Sheet — ${generatedDate}`;
|
||||
const hasCustomNote = Boolean(customNote?.trim());
|
||||
|
||||
// Build product rows HTML
|
||||
const productRows = products
|
||||
.filter(p => p.availability === "available")
|
||||
.map(p => {
|
||||
const tiersHtml = p.price_tiers
|
||||
.map(t => {
|
||||
const max = t.max_qty === 0 ? "+" : `-${t.max_qty}`;
|
||||
return `${t.min_qty}${max}: $${Number(t.price).toFixed(2)}`;
|
||||
})
|
||||
.join(" | ");
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid #e5e7eb;">
|
||||
<td style="padding: 10px 12px; font-weight: 500; color: #1e293b;">${p.name}</td>
|
||||
<td style="padding: 10px 12px; color: #64748b; font-size: 13px;">${p.description ?? "—"}</td>
|
||||
<td style="padding: 10px 12px; color: #64748b; font-size: 13px; text-align: center;">${p.unit_type}</td>
|
||||
<td style="padding: 10px 12px; color: #1e293b; font-size: 13px; text-align: right; font-family: monospace;">${tiersHtml}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${brandName} Wholesale Price Sheet</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
<div style="max-width: 700px; margin: 30px auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<div style="background: #1e293b; padding: 28px 36px;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 22px; font-weight: 700;">${brandName}</h1>
|
||||
<p style="margin: 6px 0 0; color: #94a3b8; font-size: 14px;">Wholesale Price Sheet — ${generatedDate}</p>
|
||||
${pickupLocation ? `<p style="margin: 8px 0 0; color: #94a3b8; font-size: 13px;"><strong>Pickup:</strong> ${pickupLocation}</p>` : ""}
|
||||
</div>
|
||||
|
||||
<!-- Intro -->
|
||||
<div style="padding: 20px 36px 0; border-bottom: 1px solid #f1f5f9;">
|
||||
${hasCustomNote ? ` <p style="margin: 0 0 12px; color: #1e293b; font-size: 14px; font-weight: 600; line-height: 1.5;">${customNote!.trim()}</p>` : ""}
|
||||
<p style="margin: ${hasCustomNote ? "0 0 12px" : "0 0 12px"}; color: #475569; font-size: 14px; line-height: 1.5;">
|
||||
Thank you for your wholesale account. Below is our current product availability and wholesale pricing.
|
||||
Prices shown are per unit. Contact us if you have questions about placing an order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Table -->
|
||||
<div style="padding: 20px 36px;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
||||
<thead>
|
||||
<tr style="background: #f8fafc;">
|
||||
<th style="padding: 8px 12px; text-align: left; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Product</th>
|
||||
<th style="padding: 8px 12px; text-align: left; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Description</th>
|
||||
<th style="padding: 8px 12px; text-align: center; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Unit</th>
|
||||
<th style="padding: 8px 12px; text-align: right; color: #475569; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Price Tiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${productRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 16px 36px; background: #f8fafc; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.5;">
|
||||
Prices subject to change. This price sheet was generated on ${generatedDate}. Contact your wholesale representative with any questions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `${brandName} Wholesale Price Sheet — ${generatedDate}\n${"=".repeat(50)}\n${hasCustomNote ? `\n${customNote!.trim()}\n\n` : ""}${products.filter(p => p.availability === "available").map(p => {
|
||||
const tiers = p.price_tiers.map(t => `${t.min_qty}${t.max_qty === 0 ? "+" : `-${t.max_qty}`}: $${Number(t.price).toFixed(2)}`).join(", ");
|
||||
return `${p.name} (${p.unit_type})\n ${tiers}`;
|
||||
}).join("\n\n")}\n\nGenerated ${generatedDate}. Prices subject to change.`;
|
||||
|
||||
let enqueued = 0;
|
||||
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) } }
|
||||
);
|
||||
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) {
|
||||
enqueued++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget trigger to process the queue
|
||||
fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL!}/api/wholesale/notifications/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}).catch(() => {});
|
||||
|
||||
return NextResponse.json({ enqueued, failed, total: customerIds.length });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// 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) {
|
||||
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<{
|
||||
id: string;
|
||||
brand_id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
payload: Record<string, unknown> | null;
|
||||
attempts: number;
|
||||
url: string;
|
||||
secret: string;
|
||||
}>;
|
||||
|
||||
if (!pending || pending.length === 0) {
|
||||
return NextResponse.json({ message: "No pending webhooks.", dispatched: 0 });
|
||||
}
|
||||
|
||||
let dispatched = 0;
|
||||
|
||||
for (const webhook of pending) {
|
||||
const payload = webhook.payload ?? {};
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Build HMAC-SHA256 signature: HMAC(secret, payload_string)
|
||||
const signature = crypto
|
||||
.createHmac("sha256", webhook.secret)
|
||||
.update(payloadString)
|
||||
.digest("hex");
|
||||
|
||||
try {
|
||||
const res = await fetch(webhook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Signature": `sha256=${signature}`,
|
||||
"X-Webhook-Event": webhook.event_type,
|
||||
},
|
||||
body: payloadString,
|
||||
});
|
||||
|
||||
const responseText = await res.text().catch(() => "");
|
||||
|
||||
if (res.ok) {
|
||||
await markSent(webhook.id, serviceRoleKey, supabaseUrl, `HTTP ${res.status}: ${responseText.slice(0, 200)}`);
|
||||
dispatched++;
|
||||
} else {
|
||||
await markFailed(webhook.id, serviceRoleKey, supabaseUrl, `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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
description: "POST to process pending webhook events",
|
||||
events: ["order_created", "order_fulfilled", "deposit_recorded", "order_paid"],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user