Production upgrade: Clerk auth, Stripe billing, analytics, PWA support
Backend & Auth: - Add @clerk/nextjs for production authentication - Create src/proxy.ts with clerkMiddleware() for route protection - Implement multi-tenant auth with role-based access control - Add Clerk components (Show, UserButton, SignInButton, SignUpButton) Billing & Payments: - Full Stripe integration (subscriptions, add-ons, customer portal) - Plan tiers: Starter 9/mo, Farm 49/mo, Enterprise 99/mo - Webhook handling for subscription events - createSubscription(), createAddonSubscription(), createCustomerPortalSession() API & Security: - Rate limiting with @upstash/ratelimit (100 req/min API, 20 req/min checkout) - Zod validation schemas for all endpoints (orders, products, campaigns, etc.) - Security headers (CSP, HSTS, X-Frame-Options) - API routes: /api/v1/ with validated, rate-limited endpoints Monitoring: - Sentry error tracking with performance monitoring - PostHog analytics for feature usage, funnels, cohorts - User activity logging and breadcrumb tracking Admin Features: - Analytics dashboard with revenue charts, customer growth, conversion funnel - Onboarding flow with 6-step interactive tour - Referral system with share tracking and reward redemption - Changelog feed with in-app notifications PWA & SEO: - Web app manifest with icons and shortcuts - Service worker for offline support and caching - Full SEO metadata, OpenGraph, Twitter cards - Structured data (JSON-LD) for organization and products Database: - Add referral_codes, changelogs, onboarding_progress tables - Add user_activity_logs, api_keys, notification_preferences - Comprehensive RLS policies for all new tables - Seed data for demo brands and products
This commit is contained in:
@@ -1,466 +1,50 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { sendPastDueNotification } from "@/lib/billing";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
// Stripe Webhook Handler
|
||||
// Handles all Stripe subscription and payment events
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Feature flag key for each add-on Stripe price ID
|
||||
const PRICE_TO_FEATURE: Record<string, string> = {
|
||||
[process.env.STRIPE_PRICE_HARVEST_REACH ?? ""]: "harvest_reach",
|
||||
[process.env.STRIPE_PRICE_WHOLESALE_PORTAL ?? ""]: "wholesale_portal",
|
||||
[process.env.STRIPE_PRICE_WATER_LOG ?? ""]: "water_log",
|
||||
[process.env.STRIPE_PRICE_AI_TOOLS ?? ""]: "ai_tools",
|
||||
[process.env.STRIPE_PRICE_SQUARE_SYNC ?? ""]: "square_sync",
|
||||
[process.env.STRIPE_PRICE_SMS_CAMPAIGNS ?? ""]: "sms_campaigns",
|
||||
};
|
||||
|
||||
const PLAN_PRICE_TO_TIER: Record<string, string> = {
|
||||
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
|
||||
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
|
||||
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
|
||||
};
|
||||
import { NextRequest } from "next/server";
|
||||
import { processWebhook } from "@/lib/stripe-billing";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!sig || !webhookSecret) {
|
||||
return new NextResponse("Missing signature or webhook secret", { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = Stripe.webhooks.constructEvent(body, sig, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Webhook verification failed";
|
||||
return new NextResponse(`Webhook Error: ${message}`, { status: 400 });
|
||||
}
|
||||
|
||||
// ── Subscription events ─────────────────────────────────────────────────────
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await handleCheckoutCompleted(session, req);
|
||||
}
|
||||
|
||||
if (event.type === "customer.subscription.updated") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionUpdated(subscription);
|
||||
}
|
||||
|
||||
if (event.type === "customer.subscription.deleted") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await handleSubscriptionDeleted(subscription);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_succeeded") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await handleInvoicePaid(invoice);
|
||||
}
|
||||
|
||||
if (event.type === "invoice.payment_failed") {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
await handleInvoiceFailed(invoice);
|
||||
}
|
||||
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// ── Subscription handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session, req: NextRequest) {
|
||||
const brandId = session.metadata?.brand_id;
|
||||
if (!brandId || session.mode !== "subscription") return;
|
||||
|
||||
const subscriptionId = typeof session.subscription === "string"
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId) return;
|
||||
|
||||
// Update brand subscription tracking
|
||||
await updateBrandSubscription(brandId, subscriptionId, "active");
|
||||
|
||||
// Also handle wholesale deposit payments via same event
|
||||
const orderId = session.metadata?.order_id;
|
||||
const customerId = session.metadata?.customer_id;
|
||||
const paymentBrandId = session.metadata?.brand_id;
|
||||
|
||||
if (orderId) {
|
||||
const paymentIntentId =
|
||||
typeof session.payment_intent === "string"
|
||||
? session.payment_intent
|
||||
: session.payment_intent?.id ?? null;
|
||||
const amountPaid = session.amount_total ? session.amount_total / 100 : 0;
|
||||
await recordPayment(orderId, amountPaid, paymentIntentId);
|
||||
|
||||
if (customerId && paymentBrandId) {
|
||||
await enqueueDepositNotification(orderId, customerId, paymentBrandId, amountPaid);
|
||||
await enqueueWholesaleWebhookForDepositRecorded(orderId, amountPaid, paymentBrandId);
|
||||
await maybeFireOrderPaidWebhook(orderId, paymentBrandId);
|
||||
fetch(`${req.nextUrl.origin}/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
|
||||
const signature = req.headers.get("stripe-signature");
|
||||
|
||||
if (!signature) {
|
||||
return Response.json(
|
||||
{ error: "Missing stripe-signature header" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) {
|
||||
// Try to find brand via customer
|
||||
const brand = await findBrandByStripeCustomer(subscription.customer as string);
|
||||
if (brand) {
|
||||
await updateBrandSubscription(brand.id, subscription.id, subscription.status);
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = await req.arrayBuffer();
|
||||
const payload = Buffer.from(rawBody);
|
||||
|
||||
// Process the webhook
|
||||
const result = await processWebhook(payload, signature);
|
||||
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
console.error("Webhook error:", error);
|
||||
|
||||
// Return 400 for signature verification failures
|
||||
if (error instanceof Error && error.message.includes("signature")) {
|
||||
return Response.json(
|
||||
{ error: "Invalid signature" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await updateBrandSubscription(brandId, subscription.id, subscription.status);
|
||||
await syncAddonFeatures(subscription);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) {
|
||||
const brand = await findBrandByStripeCustomer(subscription.customer as string);
|
||||
if (brand) {
|
||||
await clearBrandSubscription(brand.id);
|
||||
await disableAllAddons(brand.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await clearBrandSubscription(brandId);
|
||||
await disableAllAddons(brandId);
|
||||
}
|
||||
|
||||
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||
// Update period end on successful payment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subscriptionId = (invoice as any).subscription as string | null;
|
||||
if (!subscriptionId) return;
|
||||
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return;
|
||||
const brand = await findBrandByStripeCustomer(customerId);
|
||||
if (!brand) return;
|
||||
|
||||
const periodEnd = new Date((invoice.lines.data[0]?.period?.end ?? 0) * 1000);
|
||||
await updateBrandPeriodEnd(brand.id, periodEnd);
|
||||
}
|
||||
|
||||
async function handleInvoiceFailed(invoice: Stripe.Invoice) {
|
||||
const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
|
||||
if (!customerId) return;
|
||||
const brand = await findBrandByStripeCustomer(customerId);
|
||||
if (!brand) return;
|
||||
|
||||
// Update subscription status to past_due
|
||||
const subscriptionId = (invoice as any).subscription as string | null;
|
||||
if (subscriptionId) {
|
||||
await updateBrandSubscriptionStatus(brand.id, subscriptionId, "past_due");
|
||||
}
|
||||
|
||||
// Send past due notification email to brand admin
|
||||
await sendPastDueNotification(brand.id);
|
||||
}
|
||||
|
||||
// ── Feature sync helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function syncAddonFeatures(subscription: Stripe.Subscription) {
|
||||
const brandId = subscription.metadata?.brand_id;
|
||||
if (!brandId) return;
|
||||
|
||||
// Get the price IDs in this subscription
|
||||
const activePriceIds = subscription.items.data.map(item => item.price.id);
|
||||
|
||||
// Enable/disable add-ons based on what's in the subscription
|
||||
for (const [priceId, featureKey] of Object.entries(PRICE_TO_FEATURE)) {
|
||||
if (!priceId) continue;
|
||||
const isActive = activePriceIds.includes(priceId);
|
||||
await setBrandFeature(brandId, featureKey, isActive);
|
||||
}
|
||||
|
||||
// Handle plan tier change
|
||||
for (const [priceId, tier] of Object.entries(PLAN_PRICE_TO_TIER)) {
|
||||
if (!priceId) continue;
|
||||
if (activePriceIds.includes(priceId)) {
|
||||
await updatePlanTier(brandId, tier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setBrandFeature(brandId: string, featureKey: string, enabled: boolean) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_feature_key: featureKey, p_enabled: enabled }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePlanTier(brandId: string, planTier: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ p_brand_id: brandId, p_plan_tier: planTier }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── DB update helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function updateBrandSubscription(brandId: string, subscriptionId: string, status: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subscriptionId,
|
||||
p_status: status,
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateBrandPeriodEnd(brandId: string, periodEnd: Date) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: null,
|
||||
p_status: null,
|
||||
p_current_period_end: periodEnd.toISOString(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function clearBrandSubscription(brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: "",
|
||||
p_status: "canceled",
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function updateBrandSubscriptionStatus(brandId: string, subscriptionId: string, status: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_subscription_id: subscriptionId,
|
||||
p_status: status,
|
||||
p_current_period_end: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function disableAllAddons(brandId: string) {
|
||||
const ADDON_KEYS = ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "square_sync", "sms_campaigns"];
|
||||
for (const key of ADDON_KEYS) {
|
||||
await setBrandFeature(brandId, key, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function findBrandByStripeCustomer(customerId: string): Promise<{ id: string } | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/brands?stripe_customer_id=eq.${customerId}&select=id&limit=1`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const brands = await res.json() as Array<{ id: string }>;
|
||||
return brands[0] ?? null;
|
||||
}
|
||||
|
||||
// ── Wholesale payment helpers (existing) ───────────────────────────────────────
|
||||
|
||||
async function recordPayment(orderId: string, amount: number, paymentIntentId: string | null) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/record_wholesale_payment`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
p_order_id: orderId,
|
||||
p_amount: amount,
|
||||
p_stripe_payment_intent_id: paymentIntentId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function enqueueDepositNotification(orderId: string, customerId: string, brandId: string, amount: number) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
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;
|
||||
const orders = await orderRes.json() as Array<{ id: string; invoice_number: string | null; company_name: string; subtotal: number }>;
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
if (!order) return;
|
||||
|
||||
const custRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_customers?id=eq.${customerId}&select=email,company_name`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!custRes.ok) return;
|
||||
const customers = await custRes.json() as Array<{ email: string; company_name: string }>;
|
||||
const customer = customers[0];
|
||||
if (!customer?.email) return;
|
||||
|
||||
const adminEmail = await getAdminEmail(brandId);
|
||||
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_customer_id: customerId,
|
||||
p_order_id: orderId,
|
||||
p_type: "deposit_received",
|
||||
p_email_to: customer.email,
|
||||
p_email_cc: adminEmail ?? null,
|
||||
p_subject: `Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
p_body_html: `<h2>Deposit Received</h2><p>Hi ${customer.company_name},</p><p>We have received your payment of <strong>$${amount.toFixed(2)}</strong> toward order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
|
||||
p_body_text: `Deposit of $${amount.toFixed(2)} received for order ${order.invoice_number ?? orderId.slice(0, 8)}. Thank you!`,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (adminEmail) {
|
||||
await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_notification`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brandId,
|
||||
p_customer_id: customerId,
|
||||
p_order_id: orderId,
|
||||
p_type: "deposit_received",
|
||||
p_email_to: adminEmail,
|
||||
p_subject: `[Admin] Deposit Received — Order ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
p_body_html: `<h2>Deposit Received</h2><p>A payment of <strong>$${amount.toFixed(2)}</strong> has been received from <strong>${customer.company_name}</strong> for order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong>.</p>`,
|
||||
p_body_text: `Deposit of $${amount.toFixed(2)} received from ${customer.company_name} for order ${order.invoice_number ?? orderId.slice(0, 8)}.`,
|
||||
}),
|
||||
}
|
||||
|
||||
// Return 500 for other errors
|
||||
return Response.json(
|
||||
{ error: "Webhook processing failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "deposit_recorded",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, amount, source: "stripe_checkout" },
|
||||
}),
|
||||
}).catch(() => { /* webhook enqueue failed silently */ });
|
||||
}
|
||||
|
||||
async function maybeFireOrderPaidWebhook(orderId: string, brandId: string) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/wholesale_orders?id=eq.${orderId}&select=balance_due,invoice_number`,
|
||||
{ ...svcHeaders(supabaseKey) }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const orders = await res.json() as Array<{ balance_due: number; invoice_number: string | null }>;
|
||||
const order = orders[0];
|
||||
if (!order) return;
|
||||
|
||||
if (Number(order.balance_due) <= 0) {
|
||||
await fetch(`${supabaseUrl}/rest/v1/rpc/enqueue_wholesale_webhook`, {
|
||||
method: "POST",
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
p_event_type: "order_paid",
|
||||
p_order_id: orderId,
|
||||
p_brand_id: brandId,
|
||||
p_payload: { order_id: orderId, brand_id: brandId, invoice_number: order.invoice_number },
|
||||
}),
|
||||
}).catch(() => { /* webhook enqueue failed silently */ });
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdminEmail(brandId: string): Promise<string | null> {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = 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 }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.notification_email ?? data?.from_email ?? data?.invoice_business_email ?? null;
|
||||
}
|
||||
// Stripe requires raw body, so disable body parsing
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,495 @@
|
||||
// Production API Routes with Rate Limiting and Validation
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
apiLimiter,
|
||||
checkoutLimiter,
|
||||
emailLimiter,
|
||||
checkRateLimit,
|
||||
apiResponse,
|
||||
apiError,
|
||||
validationError,
|
||||
rateLimitExceeded,
|
||||
securityHeaders
|
||||
} from "@/lib/rate-limit";
|
||||
import { analytics } from "@/lib/analytics";
|
||||
import { captureError } from "@/lib/sentry";
|
||||
|
||||
// ============================================
|
||||
// API HEALTH CHECK
|
||||
// ============================================
|
||||
|
||||
export async function GET() {
|
||||
return apiResponse({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || "1.0.0",
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORDERS API
|
||||
// ============================================
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
customer_email: z.string().email().optional(),
|
||||
customer_name: z.string().min(1).max(255).optional(),
|
||||
customer_phone: z.string().regex(/^\+?[\d\s-()]+$/).optional(),
|
||||
customer_address: z.string().optional(),
|
||||
items: z.array(z.object({
|
||||
product_id: z.string().uuid(),
|
||||
quantity: z.number().int().positive(),
|
||||
price: z.number().positive(),
|
||||
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
|
||||
})).min(1),
|
||||
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
|
||||
stop_id: z.string().uuid().optional(),
|
||||
promo_code: z.string().optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(checkoutLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createOrderSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const { brand_id, ...orderData } = validation.data;
|
||||
|
||||
// Create order via RPC
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_order_with_items`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: brand_id,
|
||||
p_customer_email: orderData.customer_email,
|
||||
p_customer_name: orderData.customer_name,
|
||||
p_customer_phone: orderData.customer_phone,
|
||||
p_customer_address: orderData.customer_address,
|
||||
p_items: orderData.items,
|
||||
p_fulfillment: orderData.fulfillment || "pickup",
|
||||
p_stop_id: orderData.stop_id,
|
||||
p_promo_code: orderData.promo_code,
|
||||
p_notes: orderData.notes,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create order", 500);
|
||||
}
|
||||
|
||||
const order = await res.json();
|
||||
|
||||
// Track analytics
|
||||
analytics.orderCreated(order.id, orderData.items.reduce((sum, i) => sum + i.price * i.quantity, 0), brand_id);
|
||||
|
||||
return apiResponse(order, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/orders", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRODUCTS API
|
||||
// ============================================
|
||||
|
||||
const getProductsSchema = z.object({
|
||||
brand_id: z.string().uuid().optional(),
|
||||
category: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
search: z.string().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id") || undefined,
|
||||
category: searchParams.get("category") || undefined,
|
||||
is_active: searchParams.get("is_active") === "true" ? true : searchParams.get("is_active") === "false" ? false : undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
limit: parseInt(searchParams.get("limit") || "20"),
|
||||
offset: parseInt(searchParams.get("offset") || "0"),
|
||||
};
|
||||
|
||||
const validation = getProductsSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
// Build query
|
||||
let query = `${supabaseUrl}/rest/v1/products?select=*&order=name.asc&limit=${validation.data.limit}&offset=${validation.data.offset}`;
|
||||
|
||||
if (validation.data.brand_id) {
|
||||
query += `&brand_id=eq.${validation.data.brand_id}`;
|
||||
}
|
||||
if (validation.data.category) {
|
||||
query += `&category=ilike.*${encodeURIComponent(validation.data.category)}*`;
|
||||
}
|
||||
if (validation.data.is_active !== undefined) {
|
||||
query += `&is_active=eq.${validation.data.is_active}`;
|
||||
}
|
||||
if (validation.data.search) {
|
||||
query += `&or=(name.ilike.*${encodeURIComponent(validation.data.search)}*,description.ilike.*${encodeURIComponent(validation.data.search)}*)`;
|
||||
}
|
||||
|
||||
const res = await fetch(query, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to fetch products", 500);
|
||||
}
|
||||
|
||||
const products = await res.json();
|
||||
|
||||
// Track search analytics
|
||||
if (validation.data.search) {
|
||||
analytics.searchPerformed(validation.data.search, products.length, "products");
|
||||
}
|
||||
|
||||
return apiResponse(products, 200, {
|
||||
limit: validation.data.limit,
|
||||
offset: validation.data.offset,
|
||||
count: products.length,
|
||||
}, {
|
||||
...securityHeaders(),
|
||||
...rateLimitHeaders(rateCheck),
|
||||
});
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/products", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CAMPAIGNS API (Email/SMS)
|
||||
// ============================================
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(500),
|
||||
content: z.string().min(1),
|
||||
type: z.enum(["email", "sms"]).default("email"),
|
||||
segment_id: z.string().uuid().optional(),
|
||||
contact_ids: z.array(z.string().uuid()).optional(),
|
||||
scheduled_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createCampaignSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create campaign via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_campaign`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_name: validation.data.name,
|
||||
p_subject: validation.data.subject,
|
||||
p_content: validation.data.content,
|
||||
p_type: validation.data.type,
|
||||
p_segment_id: validation.data.segment_id,
|
||||
p_contact_ids: validation.data.contact_ids,
|
||||
p_scheduled_at: validation.data.scheduled_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create campaign", 500);
|
||||
}
|
||||
|
||||
const campaign = await res.json();
|
||||
|
||||
// Track analytics
|
||||
const audienceSize = validation.data.contact_ids?.length || 0;
|
||||
analytics.campaignCreated(campaign.id, validation.data.type, audienceSize);
|
||||
|
||||
return apiResponse(campaign, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/campaigns", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WATER LOG API
|
||||
// ============================================
|
||||
|
||||
const createWaterLogSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
field_id: z.string().uuid().optional(),
|
||||
field_name: z.string().max(255).optional(),
|
||||
gallons: z.number().positive(),
|
||||
duration_minutes: z.number().int().nonnegative().optional(),
|
||||
water_method: z.enum(["drip", "sprinkler", "flood", "manual"]).optional(),
|
||||
notes: z.string().max(500).optional(),
|
||||
logged_at: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse and validate body
|
||||
const body = await req.json();
|
||||
const validation = createWaterLogSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Create water log via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/create_water_log`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_field_id: validation.data.field_id,
|
||||
p_field_name: validation.data.field_name,
|
||||
p_gallons: validation.data.gallons,
|
||||
p_duration_minutes: validation.data.duration_minutes,
|
||||
p_water_method: validation.data.water_method,
|
||||
p_notes: validation.data.notes,
|
||||
p_logged_at: validation.data.logged_at,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to create water log", 500);
|
||||
}
|
||||
|
||||
const waterLog = await res.json();
|
||||
|
||||
return apiResponse(waterLog, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/water-logs", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REPORTS API
|
||||
// ============================================
|
||||
|
||||
const reportFiltersSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
start_date: z.string().datetime(),
|
||||
end_date: z.string().datetime(),
|
||||
report_type: z.enum(["orders", "revenue", "customers", "products"]).default("orders"),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(req.url);
|
||||
const params = {
|
||||
brand_id: searchParams.get("brand_id")!,
|
||||
start_date: searchParams.get("start_date")!,
|
||||
end_date: searchParams.get("end_date")!,
|
||||
report_type: searchParams.get("report_type") || "orders",
|
||||
};
|
||||
|
||||
const validation = reportFiltersSchema.safeParse(params);
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Get report via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/generate_report`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_start_date: validation.data.start_date,
|
||||
p_end_date: validation.data.end_date,
|
||||
p_report_type: validation.data.report_type,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return apiError("Failed to generate report", 500);
|
||||
}
|
||||
|
||||
const report = await res.json();
|
||||
|
||||
return apiResponse(report, 200);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/reports", method: "GET" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// REFERRAL API
|
||||
// ============================================
|
||||
|
||||
const createReferralSchema = z.object({
|
||||
brand_id: z.string().uuid(),
|
||||
referred_email: z.string().email(),
|
||||
referral_code: z.string().min(6).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
|
||||
if (!rateCheck.success) {
|
||||
return rateLimitExceeded(Math.ceil((rateCheck.reset - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const validation = createReferralSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return validationError(validation.error);
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Redeem referral via RPC
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/redeem_referral`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_brand_id: validation.data.brand_id,
|
||||
p_referred_email: validation.data.referred_email,
|
||||
p_referral_code: validation.data.referral_code,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
return apiError(error.message || "Failed to redeem referral", 500);
|
||||
}
|
||||
|
||||
const referral = await res.json();
|
||||
|
||||
analytics.referralCompleted(validation.data.referral_code, referral.referred_user_id);
|
||||
|
||||
return apiResponse(referral, 201);
|
||||
} catch (error) {
|
||||
captureError(error as Error, { path: "/api/referrals", method: "POST" });
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// OPTIONS HANDLER (CORS preflight)
|
||||
// ============================================
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...securityHeaders(),
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
+7
-1
@@ -1,9 +1,13 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
|
||||
|
||||
// Clerk publishable key
|
||||
const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || "";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
@@ -50,7 +54,9 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<ClerkProvider publishableKey={publishableKey}>
|
||||
<Providers>{children}</Providers>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user