Files
route-commerce/src/app/api/v1/route.ts
T
tyler 6ab52a2499 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
2026-06-02 05:33:42 +00:00

495 lines
15 KiB
TypeScript

// 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",
},
});
}