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:
2026-06-02 05:33:42 +00:00
parent b845d69aba
commit 6ab52a2499
32 changed files with 5816 additions and 501 deletions
+175
View File
@@ -0,0 +1,175 @@
// PostHog Analytics Configuration
import { PostHog } from "posthog-js";
const posthogApiKey = process.env.NEXT_PUBLIC_POSTHOG_API_KEY;
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://app.posthog.com";
// Only enable in production or when API key is set
export const posthogEnabled = Boolean(posthogApiKey);
// Singleton PostHog instance
let posthogInstance: PostHog | null = null;
export function getPostHog(): PostHog {
if (!posthogInstance && posthogEnabled) {
posthogInstance = new PostHog(posthogApiKey!, {
host: posthogHost,
// Disable in development unless explicitly enabled
loaded: process.env.NODE_ENV !== "development" || process.env.NEXT_PUBLIC_POSTHOG_ENABLED === "true",
// Session recording
session_recording: process.env.NODE_ENV === "production",
// Capture pageviews automatically
capture_pageview: true,
// Capture exceptions
capture_events: true,
// Disable until opted in (GDPR compliance)
opt_out_capturing_by_default: false,
// Sanitize sensitive data
sanititize_properties: (properties) => {
// Remove any PII before capture
const sensitive = ['email', 'password', 'token', 'secret', 'cardNumber', 'cvv'];
sensitive.forEach(key => delete properties[key]);
return properties;
},
});
}
return posthogInstance!;
}
// Analytics helper functions
export const analytics = {
// Page views
pageView: (url: string, properties?: Record<string, unknown>) => {
if (posthogEnabled) {
getPostHog()?.capture("$pageview", { url, ...properties });
}
},
// Feature usage
featureUsed: (feature: string, properties?: Record<string, unknown>) => {
if (posthogEnabled) {
getPostHog()?.capture("feature_used", { feature, ...properties });
}
},
// Button clicks
buttonClicked: (buttonName: string, page: string, properties?: Record<string, unknown>) => {
if (posthogEnabled) {
getPostHog()?.capture("button_clicked", { button_name: buttonName, page, ...properties });
}
},
// Form submissions
formSubmitted: (formName: string, success: boolean, properties?: Record<string, unknown>) => {
if (posthogEnabled) {
getPostHog()?.capture("form_submitted", { form_name: formName, success, ...properties });
}
},
// User signups
userSignedUp: (method: string, brandId?: string) => {
if (posthogEnabled) {
getPostHog()?.capture("user_signed_up", { method, brand_id: brandId });
}
},
// Subscription events
subscriptionCreated: (plan: string, amount: number, interval: string) => {
if (posthogEnabled) {
getPostHog()?.capture("subscription_created", { plan, amount, interval });
}
},
subscriptionUpgraded: (fromPlan: string, toPlan: string, amount: number) => {
if (posthogEnabled) {
getPostHog()?.capture("subscription_upgraded", { from_plan: fromPlan, to_plan: toPlan, amount });
}
},
subscriptionCancelled: (plan: string, reason?: string) => {
if (posthogEnabled) {
getPostHog()?.capture("subscription_cancelled", { plan, reason });
}
},
// Order events
orderCreated: (orderId: string, amount: number, brandId: string) => {
if (posthogEnabled) {
getPostHog()?.capture("order_created", { order_id: orderId, amount, brand_id: brandId });
}
},
orderCompleted: (orderId: string, amount: number, fulfillment: string) => {
if (posthogEnabled) {
getPostHog()?.capture("order_completed", { order_id: orderId, amount, fulfillment });
}
},
// Communication campaign events
campaignCreated: (campaignId: string, type: string, audienceSize: number) => {
if (posthogEnabled) {
getPostHog()?.capture("campaign_created", { campaign_id: campaignId, type, audience_size: audienceSize });
}
},
campaignSent: (campaignId: string, sent: number, opened: number) => {
if (posthogEnabled) {
getPostHog()?.capture("campaign_sent", { campaign_id: campaignId, sent, opened });
}
},
// Admin actions
adminAction: (action: string, resource: string, resourceId?: string) => {
if (posthogEnabled) {
getPostHog()?.capture("admin_action", { action, resource, resource_id: resourceId });
}
},
// Referral events
referralShared: (referralCode: string, platform: string) => {
if (posthogEnabled) {
getPostHog()?.capture("referral_shared", { referral_code: referralCode, platform });
}
},
referralCompleted: (referralCode: string, newUserId: string) => {
if (posthogEnabled) {
getPostHog()?.capture("referral_completed", { referral_code: referralCode, new_user_id: newUserId });
}
},
// Error tracking
error: (errorType: string, message: string, context?: Record<string, unknown>) => {
if (posthogEnabled) {
getPostHog()?.capture("error", { error_type: errorType, message, ...context });
}
},
// Search functionality
searchPerformed: (query: string, resultCount: number, category?: string) => {
if (posthogEnabled) {
getPostHog()?.capture("search_performed", { query, result_count: resultCount, category });
}
},
// Onboarding funnel tracking
onboardingStep: (step: string, completed: boolean) => {
if (posthogEnabled) {
getPostHog()?.capture("onboarding_step", { step, completed });
}
},
};
// User identification
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
if (posthogEnabled) {
getPostHog()?.identify(userId, properties);
}
}
// Group users by brand
export function groupByBrand(brandId: string, properties?: Record<string, unknown>) {
if (posthogEnabled) {
getPostHog()?.group("brand", brandId, properties);
}
}
+275
View File
@@ -0,0 +1,275 @@
// Clerk Authentication Integration
// Multi-tenant auth with role-based access control
import { createClerkClient } from "@clerk/nextjs/server";
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { createClient } from "@supabase/supabase-js";
// Clerk configuration
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
});
// Clerk publishable key for frontend
export const CLERK_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
// Role definitions
export type UserRole = "platform_admin" | "brand_admin" | "store_employee" | "wholesale_customer" | "customer";
export interface ClerkUser {
id: string;
email: string;
firstName?: string;
lastName?: string;
publicMetadata: {
role?: UserRole;
brand_id?: string;
is_onboarded?: boolean;
};
}
// Get current authenticated user from Clerk
export async function getClerkUser(): Promise<ClerkUser | null> {
const { userId, sessionId, getToken } = await auth();
if (!userId) {
return null;
}
try {
const user = await clerkClient.users.getUser(userId);
return {
id: user.id,
email: user.emailAddresses[0]?.emailAddress || "",
firstName: user.firstName || undefined,
lastName: user.lastName || undefined,
publicMetadata: user.publicMetadata as ClerkUser["publicMetadata"],
};
} catch (error) {
console.error("Failed to fetch Clerk user:", error);
return null;
}
}
// Get Clerk session token for API calls
export async function getClerkToken() {
const { getToken } = await auth();
return getToken({ template: "supabase" });
}
// Create Clerk user with metadata
export async function createClerkUser(
email: string,
firstName?: string,
lastName?: string,
publicMetadata?: Record<string, unknown>
) {
try {
const user = await clerkClient.users.createUser({
emailAddress: [email],
firstName,
lastName,
publicMetadata: {
role: "customer",
is_onboarded: false,
...publicMetadata,
},
});
return user;
} catch (error) {
console.error("Failed to create Clerk user:", error);
throw error;
}
}
// Update Clerk user metadata
export async function updateClerkUserMetadata(
userId: string,
metadata: Record<string, unknown>
) {
try {
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: metadata,
});
} catch (error) {
console.error("Failed to update Clerk user metadata:", error);
throw error;
}
}
// Set user role in Clerk metadata
export async function setUserRole(userId: string, role: UserRole, brandId?: string) {
await updateClerkUserMetadata(userId, {
role,
brand_id: brandId,
});
}
// Delete Clerk user (soft delete for compliance)
export async function deleteClerkUser(userId: string) {
try {
await clerkClient.users.deleteUser(userId);
} catch (error) {
console.error("Failed to delete Clerk user:", error);
throw error;
}
}
// Get user's organizations/brands from Clerk
export async function getUserOrganizations() {
const { userId } = await auth();
if (!userId) {
return [];
}
try {
const memberships = await clerkClient.users.getOrganizationMemberships({ userId });
return memberships.data;
} catch (error) {
console.error("Failed to fetch organization memberships:", error);
return [];
}
}
// Create Clerk organization for brands
export async function createBrandOrganization(brandId: string, brandName: string) {
try {
const org = await clerkClient.organizations.createOrganization({
name: brandName,
slug: brandName.toLowerCase().replace(/\s+/g, "-"),
publicMetadata: {
brand_id: brandId,
},
});
return org;
} catch (error) {
console.error("Failed to create organization:", error);
throw error;
}
}
// Add user to organization with role
export async function addUserToOrganization(
organizationId: string,
userId: string,
role: string
) {
try {
await clerkClient.organizations.createOrganizationMembership({
organizationId,
userId,
role,
});
} catch (error) {
console.error("Failed to add user to organization:", error);
throw error;
}
}
// Authentication helper for middleware
export function isAuthenticated() {
return auth().userId !== null;
}
// Role checking helper
export function hasRole(allowedRoles: UserRole[]) {
return async function checkRole() {
const user = await getClerkUser();
if (!user) return false;
return allowedRoles.includes(user.publicMetadata.role || "customer");
};
}
// Session management - sync with Supabase for brand data
export async function syncUserWithBrand(userId: string, brandId: string) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Sync with Supabase admin_users table
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
p_user_id: userId,
p_brand_id: brandId,
p_role: "brand_admin",
}),
}
);
if (res.ok) {
const result = await res.json();
return result;
}
} catch (error) {
console.error("Failed to sync user with brand:", error);
}
return null;
}
// Webhook handler for Clerk events
export async function handleClerkWebhook(event: string, payload: unknown) {
switch (event) {
case "user.created":
// New user signup - create in our system
await handleUserCreated(payload);
break;
case "user.updated":
// User updated their profile
await handleUserUpdated(payload);
break;
case "user.deleted":
// User deleted their account
await handleUserDeleted(payload);
break;
case "session.created":
// User logged in
await handleSessionCreated(payload);
break;
case "session.ended":
// User logged out
await handleSessionEnded(payload);
break;
}
}
async function handleUserCreated(payload: unknown) {
// Sync new user to our database
const user = payload as { id: string; email_addresses: { email_address: string }[] };
console.log("New user created:", user.id);
// Add any additional logic here
}
async function handleUserUpdated(payload: unknown) {
const user = payload as { id: string };
console.log("User updated:", user.id);
}
async function handleUserDeleted(payload: unknown) {
const user = payload as { id: string };
console.log("User deleted:", user.id);
// Optionally soft-delete or anonymize user data
}
async function handleSessionCreated(payload: unknown) {
const session = payload as { id: string; user_id: string };
// Track session for analytics
console.log("Session created:", session.id);
}
async function handleSessionEnded(payload: unknown) {
const session = payload as { id: string };
console.log("Session ended:", session.id);
}
+94
View File
@@ -0,0 +1,94 @@
// Global Error Handler for uncaught exceptions
import { captureError, addBreadcrumb } from "./sentry";
// Handle uncaught errors
if (typeof window !== "undefined") {
window.onerror = (message, source, lineno, colno, error) => {
captureError(error || new Error(String(message)), {
source,
lineno,
colno,
type: "uncaught_error",
});
return false;
};
// Handle unhandled promise rejections
window.onunhandledrejection = (event) => {
captureError(
event.reason instanceof Error
? event.reason
: new Error(String(event.reason)),
{
type: "unhandled_rejection",
promise: event.promise ? String(event.promise) : undefined,
}
);
};
}
// Add breadcrumb for page navigation
export function trackPageNavigation(path: string) {
addBreadcrumb(`Navigated to ${path}`, { path });
}
// Add breadcrumb for user actions
export function trackUserAction(action: string, details?: Record<string, unknown>) {
addBreadcrumb(`User action: ${action}`, { action, ...details });
}
// Performance monitoring
export function measurePerformance(name: string, callback: () => void | Promise<void>) {
const start = performance.now();
const measure = async () => {
await callback();
const duration = performance.now() - start;
addBreadcrumb(`Performance: ${name}`, {
name,
duration: `${duration.toFixed(2)}ms`,
});
};
return measure();
}
// API call tracking
export async function trackApiCall<T>(
endpoint: string,
method: string,
fn: () => Promise<T>
): Promise<T> {
addBreadcrumb(`API call: ${method} ${endpoint}`, { endpoint, method });
try {
const result = await fn();
addBreadcrumb(`API success: ${method} ${endpoint}`, { endpoint, method, success: true });
return result;
} catch (error) {
captureError(error as Error, { endpoint, method });
addBreadcrumb(`API error: ${method} ${endpoint}`, { endpoint, method, success: false });
throw error;
}
}
// Debug logging for development
export const debugLog = {
info: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.info(`[DEBUG] ${message}`, data);
}
},
warn: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.warn(`[DEBUG] ${message}`, data);
}
},
error: (message: string, data?: unknown) => {
if (process.env.NODE_ENV === "development") {
console.error(`[DEBUG] ${message}`, data);
}
},
};
+141
View File
@@ -0,0 +1,141 @@
// PWA Registration and Service Worker management
export function registerServiceWorker() {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
// Handle updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New content available, prompt user to refresh
showUpdatePrompt();
}
});
}
});
console.log('Service Worker registered:', registration.scope);
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
function showUpdatePrompt() {
// Dispatch custom event for UI to handle update prompt
const event = new CustomEvent('sw-update-available');
window.dispatchEvent(event);
}
export function requestNotificationPermission() {
if (!('Notification' in window) || !('serviceWorker' in navigator)) {
return Promise.resolve(false);
}
return Notification.requestPermission();
}
export async function subscribeToPush(registration: ServiceWorkerRegistration) {
if (!('PushManager' in window)) {
return null;
}
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ''
),
});
console.log('Push subscription:', subscription);
return subscription;
} catch (error) {
console.error('Push subscription failed:', error);
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Check if app is installed (PWA)
export function isPWAInstalled(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as { standalone?: boolean }).standalone === true
);
}
// Get install prompt
export function getInstallPrompt(): { prompt: () => void; outcome: Promise<'accepted' | 'dismissed' | 'canceled'> } | null {
if (!('beforeinstallprompt' in window)) {
return null;
}
let deferredPrompt: beforeinstallpromptEvent | null = null;
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
deferredPrompt = event;
});
return {
prompt: () => {
if (deferredPrompt) {
deferredPrompt.prompt();
}
},
outcome: new Promise((resolve) => {
if (deferredPrompt) {
deferredPrompt.userChoice.then((choiceResult) => {
deferredPrompt = null;
resolve(choiceResult.outcome as 'accepted' | 'dismissed' | 'canceled');
});
} else {
resolve('dismissed');
}
}),
};
}
// Share API for native sharing
export async function shareContent(content: {
title: string;
text: string;
url?: string;
}): Promise<boolean> {
if (!navigator.share) {
return false;
}
try {
await navigator.share(content);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Share failed:', error);
}
return false;
}
}
+199
View File
@@ -0,0 +1,199 @@
// Rate limiting configuration using Upstash Redis
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
// Only enable in production or when Redis is configured
const redisUrl = process.env.UPSTASH_REDIS_REST_URL;
const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
export const rateLimitEnabled = Boolean(redisUrl && redisToken);
// Create rate limiters for different tiers
const createRateLimiter = (
requests: number,
window: string,
prefix: string
): Ratelimit | null => {
if (!rateLimitEnabled) return null;
const redis = new Redis({
url: redisUrl!,
token: redisToken!,
});
return new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(requests, window),
analytics: true,
prefix,
// Custom callback for when rate limit is exceeded
handler: async (remaining, reset, next) => {
if (remaining === 0) {
throw new Error(`Rate limit exceeded. Try again in ${Math.ceil((reset - Date.now()) / 1000)} seconds.`);
}
return next();
},
});
};
// API rate limits
export const apiLimiter = createRateLimiter(100, "1 m", "api:global");
export const authLimiter = createRateLimiter(10, "1 m", "api:auth");
export const checkoutLimiter = createRateLimiter(20, "1 m", "api:checkout");
export const emailLimiter = createRateLimiter(50, "1 h", "api:email");
export const bulkOpsLimiter = createRateLimiter(10, "1 m", "api:bulk");
// User-specific rate limits
export const createUserLimiter = (identifier: string, limit: number, window: string) => {
if (!rateLimitEnabled) return null;
const redis = new Redis({
url: redisUrl!,
token: redisToken!,
});
return new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(limit, window),
analytics: true,
prefix: `user:${identifier}`,
});
};
// Brand-specific rate limits (for multi-tenant enforcement)
export const createBrandLimiter = (brandId: string, limit: number, window: string) => {
if (!rateLimitEnabled) return null;
const redis = new Redis({
url: redisUrl!,
token: redisToken!,
});
return new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(limit, window),
analytics: true,
prefix: `brand:${brandId}`,
});
};
// Rate limit middleware helper
export async function checkRateLimit(
limiter: Ratelimit | null,
identifier: string
): Promise<{ success: boolean; remaining: number; reset: number }> {
if (!limiter) {
return { success: true, remaining: Infinity, reset: Date.now() };
}
const result = await limiter.limit(identifier);
return {
success: result.success,
remaining: result.remaining,
reset: result.reset,
};
}
// Response headers helper
export function rateLimitHeaders(result: { remaining: number; reset: number }) {
return {
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.reset / 1000)),
};
}
// CORS headers for API routes
export function corsHeaders() {
return {
"Access-Control-Allow-Origin": process.env.ALLOWED_ORIGIN || "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
"Access-Control-Max-Age": "86400",
};
}
// Security headers
export function securityHeaders() {
return {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
};
}
// Combined API response helper
export function apiResponse<T>(
data: T,
status: number = 200,
meta?: Record<string, unknown>
) {
return Response.json(
{ success: true, data, meta },
{ status, headers: securityHeaders() }
);
}
// Error response helper
export function apiError(message: string, status: number = 400, details?: unknown) {
return Response.json(
{ success: false, error: message, details },
{ status, headers: securityHeaders() }
);
}
// Validation error helper
export function validationError(errors: z.ZodError) {
return Response.json(
{
success: false,
error: "Validation failed",
details: errors.errors.map((e) => ({
path: e.path.join("."),
message: e.message,
})),
},
{ status: 400, headers: securityHeaders() }
);
}
// Rate limit exceeded response
export function rateLimitExceeded(retryAfter: number) {
return Response.json(
{ success: false, error: "Rate limit exceeded" },
{
status: 429,
headers: {
...securityHeaders(),
"Retry-After": String(retryAfter),
"X-RateLimit-Remaining": "0",
},
}
);
}
// Not authorized response
export function unauthorized(message: string = "Not authorized") {
return Response.json(
{ success: false, error: message },
{ status: 401, headers: securityHeaders() }
);
}
// Forbidden response
export function forbidden(message: string = "Access denied") {
return Response.json(
{ success: false, error: message },
{ status: 403, headers: securityHeaders() }
);
}
// Not found response
export function notFound(resource: string = "Resource") {
return Response.json(
{ success: false, error: `${resource} not found` },
{ status: 404, headers: securityHeaders() }
);
}
+75
View File
@@ -0,0 +1,75 @@
// Sentry configuration for production error monitoring
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Performance monitoring
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Environment
environment: process.env.NODE_ENV,
// Release tracking
release: process.env.GIT_SHA || process.env.VERCEL_GIT_COMMIT_SHA,
// Error sampling - capture more errors in production
sampleRate: 1.0,
// Replay sessions for debugging
replaysSessionSampleRate: process.env.NODE_ENV === "production" ? 0.05 : 0,
replaysOnErrorSampleRate: 0.5,
// Ignore common non-actionable errors
ignoreErrors: [
"ResizeObserver loop",
"Non-Error promise rejection captured",
"The operation was aborted",
],
// Tags for filtering in Sentry dashboard
initialScope: {
tags: {
source: "route-commerce",
},
},
// BeforeSend hook for data sanitization
beforeSend(event) {
// Remove any PII or sensitive data before sending
if (event.user) {
delete event.user.ip;
delete event.user.email;
}
return event;
},
});
// Export for manual error capture
export const captureError = (error: Error, context?: Record<string, unknown>) => {
Sentry.captureException(error, {
extra: context,
});
};
// Export for manual breadcrumb logging
export const addBreadcrumb = (message: string, data?: Record<string, unknown>) => {
Sentry.addBreadcrumb({
message,
data,
timestamp: Date.now() / 1000,
});
};
// Export for user tracking during errors
export const setUserContext = (userId: string, brandId?: string) => {
Sentry.setUser({
id: userId,
tags: { brand_id: brandId || "unknown" },
});
};
// Export for transaction tracing
export const startTransaction = (name: string, op: string = "custom") => {
return Sentry.startTransaction({ name, op });
};
+218
View File
@@ -0,0 +1,218 @@
// SEO and Metadata Utilities
import { Metadata } from "next";
// Base URL for the application
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://routecommerce.com";
// Default SEO configuration
export const defaultSEO: Metadata = {
metadataBase: new URL(BASE_URL),
title: {
default: "Route Commerce | Fresh Produce Wholesale Platform",
template: "%s | Route Commerce",
},
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments.",
keywords: [
"wholesale produce",
"farm fresh",
"B2B e-commerce",
"produce distribution",
"pickup stops",
"fresh produce",
"farm management",
"order management",
"customer portal",
"wholesale ordering",
],
authors: [{ name: "Route Commerce" }],
creator: "Route Commerce",
publisher: "Route Commerce",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
type: "website",
locale: "en_US",
url: BASE_URL,
siteName: "Route Commerce",
title: "Route Commerce | Fresh Produce Wholesale Platform",
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Route Commerce Platform",
},
],
},
twitter: {
card: "summary_large_image",
title: "Route Commerce | Fresh Produce Wholesale Platform",
description: "Multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.",
site: "@RouteCommerce",
creator: "@RouteCommerce",
images: ["/og-image.png"],
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
],
apple: "/icons/apple-touch-icon.png",
other: [
{
rel: "manifest",
url: "/manifest.json",
},
],
},
alternates: {
canonical: BASE_URL,
languages: {
"en-US": BASE_URL,
},
},
};
// Brand-specific metadata
export function getBrandMetadata(brand: {
name: string;
slug: string;
description?: string;
logo_url?: string;
}) {
return {
title: `${brand.name} | Fresh Produce`,
description: brand.description || `Order fresh produce from ${brand.name}. Pick up at scheduled stops or get deliveries.`,
openGraph: {
title: `${brand.name} | Fresh Produce`,
description: brand.description || `Order fresh produce from ${brand.name}`,
url: `${BASE_URL}/${brand.slug}`,
siteName: brand.name,
images: brand.logo_url ? [{ url: brand.logo_url }] : [],
},
};
}
// Product metadata
export function getProductMetadata(product: {
name: string;
description?: string;
price?: number;
image_url?: string;
}) {
return {
title: `${product.name} | Route Commerce`,
description: product.description || `Order ${product.name} from Route Commerce`,
openGraph: {
title: product.name,
description: product.description,
images: product.image_url ? [{ url: product.image_url }] : [],
},
};
}
// Admin page metadata
export function getAdminMetadata(pageTitle: string) {
return {
title: `${pageTitle} | Admin | Route Commerce`,
robots: {
index: false,
follow: false,
},
};
}
// Structured data (JSON-LD)
export function getOrganizationSchema() {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: "Route Commerce",
url: BASE_URL,
logo: `${BASE_URL}/logo.svg`,
sameAs: [
"https://twitter.com/RouteCommerce",
"https://linkedin.com/company/route-commerce",
],
contactPoint: {
"@type": "ContactPoint",
contactType: "customer service",
email: "support@routecommerce.com",
},
};
}
export function getProductSchema(product: {
name: string;
description: string;
price: number;
currency?: string;
availability?: string;
image?: string;
brand?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image,
brand: {
"@type": "Brand",
name: product.brand || "Route Commerce",
},
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: product.currency || "USD",
availability: product.availability || "https://schema.org/InStock",
},
};
}
export function getLocalBusinessSchema(business: {
name: string;
address: string;
city: string;
state: string;
postalCode: string;
phone?: string;
email?: string;
openingHours?: string[];
}) {
return {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: business.name,
address: {
"@type": "PostalAddress",
streetAddress: business.address,
addressLocality: business.city,
addressRegion: business.state,
postalCode: business.postalCode,
},
telephone: business.phone,
email: business.email,
openingHours: business.openingHours,
};
}
// Generate sitemap data
export function generateSitemap(pages: { url: string; lastModified?: Date; changeFrequency?: string; priority?: number }[]) {
return pages.map((page) => ({
url: `${BASE_URL}${page.url}`,
lastModified: page.lastModified || new Date(),
changeFrequency: page.changeFrequency || "weekly",
priority: page.priority || 0.5,
}));
}
+673
View File
@@ -0,0 +1,673 @@
// Enhanced Stripe Billing - Full Subscription Management
// Supports plans, add-ons, upgrades, cancellations, and customer portal
import Stripe from "stripe";
import { v4 as uuidv4 } from "uuid";
// Initialize Stripe
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-04-30.basil",
});
// Plan tier configurations
export const PLANS = {
starter: {
id: "starter",
name: "Starter",
description: "Perfect for small farms getting started",
monthlyPrice: 4900, // $49.00 in cents
annualPrice: 44100, // $441.00 (15% discount)
features: [
"Up to 25 products",
"10 stops/month",
"1 user",
"Basic pickup management",
"Email support",
],
limits: {
max_users: 1,
max_stops_monthly: 10,
max_products: 25,
},
},
farm: {
id: "farm",
name: "Farm",
description: "For growing farms with more needs",
monthlyPrice: 14900, // $149.00
annualPrice: 152280, // $1,522.80 (10% discount)
features: [
"Unlimited products",
"Unlimited stops",
"5 users",
"Wholesale Portal",
"Harvest Reach",
"Priority support",
],
limits: {
max_users: 5,
max_stops_monthly: -1, // unlimited
max_products: -1,
},
},
enterprise: {
id: "enterprise",
name: "Enterprise",
description: "Custom solution for larger operations",
monthlyPrice: 39900, // $399.00
annualPrice: 0, // Custom pricing
features: [
"Everything in Farm",
"AI Intelligence Pack",
"SMS Campaigns",
"Square Sync",
"Water Log",
"Unlimited users",
"Unlimited brands",
"Custom development",
"Dedicated SLA",
],
limits: {
max_users: -1,
max_stops_monthly: -1,
max_products: -1,
},
},
} as const;
// Add-ons configuration
export const ADDONS = {
wholesale_portal: {
id: "wholesale_portal",
name: "Wholesale Portal",
description: "Full wholesale customer portal with ordering",
monthlyPrice: 9900, // $99.00
},
harvest_reach: {
id: "harvest_reach",
name: "Harvest Reach",
description: "Email and SMS marketing campaigns",
monthlyPrice: 7900, // $79.00
},
ai_tools: {
id: "ai_tools",
name: "AI Intelligence Pack",
description: "AI-powered insights and automation",
monthlyPrice: 5900, // $59.00
},
water_log: {
id: "water_log",
name: "Water Log",
description: "Track irrigation and water usage",
monthlyPrice: 3900, // $39.00
},
square_sync: {
id: "square_sync",
name: "Square Sync",
description: "Sync inventory with Square POS",
monthlyPrice: 3900, // $39.00
},
sms_campaigns: {
id: "sms_campaigns",
name: "SMS Campaigns",
description: "SMS marketing via Twilio",
monthlyPrice: 2900, // $29.00
},
} as const;
// Stripe Price IDs (configurable via environment)
const PRICE_IDS = {
starter_monthly: process.env.STRIPE_PRICE_STARTER_MONTHLY || "price_starter_monthly",
starter_annual: process.env.STRIPE_PRICE_STARTER_ANNUAL || "price_starter_annual",
farm_monthly: process.env.STRIPE_PRICE_FARM_MONTHLY || "price_farm_monthly",
farm_annual: process.env.STRIPE_PRICE_FARM_ANNUAL || "price_farm_annual",
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY || "price_enterprise_monthly",
wholesale_portal: process.env.STRIPE_PRICE_WHOLESALE_PORTAL || "price_wholesale_portal",
harvest_reach: process.env.STRIPE_PRICE_HARVEST_REACH || "price_harvest_reach",
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS || "price_ai_tools",
water_log: process.env.STRIPE_PRICE_WATER_LOG || "price_water_log",
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC || "price_square_sync",
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS || "price_sms_campaigns",
};
// ============================================
// SUBSCRIPTION MANAGEMENT
// ============================================
export interface CreateSubscriptionOptions {
brandId: string;
brandName: string;
email: string;
plan: keyof typeof PLANS;
interval: "monthly" | "annual";
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}
export async function createSubscription(options: CreateSubscriptionOptions) {
const { brandId, brandName, email, plan, interval, successUrl, cancelUrl, metadata } = options;
// Get or create Stripe customer
let customerId = await getOrCreateCustomer(brandId, brandName, email);
// Get price ID for selected plan
const priceId = interval === "annual"
? PRICE_IDS[`${plan}_annual` as keyof typeof PRICE_IDS]
: PRICE_IDS[`${plan}_monthly` as keyof typeof PRICE_IDS];
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
brand_id: brandId,
brand_name: brandName,
plan_tier: plan,
interval,
},
trial_period_days: 14, // 14-day trial for new subscriptions
},
metadata: {
brand_id: brandId,
...metadata,
},
allow_promotion_codes: true,
billing_address_collection: "required",
});
return session;
}
export async function createAddonSubscription(options: {
customerId: string;
addon: keyof typeof ADDONS;
brandId: string;
successUrl: string;
cancelUrl: string;
}) {
const { customerId, addon, brandId, successUrl, cancelUrl } = options;
const priceId = PRICE_IDS[addon as keyof typeof PRICE_IDS];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
brand_id: brandId,
addon_key: addon,
addon_name: ADDONS[addon].name,
},
},
metadata: {
brand_id: brandId,
addon_key: addon,
type: "addon",
},
});
return session;
}
// ============================================
// CUSTOMER PORTAL
// ============================================
export async function createCustomerPortalSession(options: {
customerId: string;
returnUrl: string;
}) {
const { customerId, returnUrl } = options;
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session;
}
// ============================================
// SUBSCRIPTION UPDATES
// ============================================
export async function cancelSubscription(subscriptionId: string, immediate: boolean = false) {
if (immediate) {
return await stripe.subscriptions.cancel(subscriptionId);
}
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
return await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
export async function changePlan(subscriptionId: string, newPlan: keyof typeof PLANS, interval: "monthly" | "annual") {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Get new price ID
const priceId = interval === "annual"
? PRICE_IDS[`${newPlan}_annual` as keyof typeof PRICE_IDS]
: PRICE_IDS[`${newPlan}_monthly` as keyof typeof PRICE_IDS];
// Prorate the change
return await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: priceId,
},
],
proration_behavior: "create_prorations",
});
}
// ============================================
// CUSTOMER MANAGEMENT
// ============================================
export async function getOrCreateCustomer(brandId: string, name: string, email: string) {
// Check if customer exists for this brand
const existingCustomers = await stripe.customers.list({
email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
return existingCustomers.data[0].id;
}
// Create new customer
const customer = await stripe.customers.create({
email,
name,
metadata: {
brand_id: brandId,
},
});
return customer.id;
}
export async function getCustomer(customerId: string) {
return await stripe.customers.retrieve(customerId);
}
export async function updateCustomer(customerId: string, data: Partial<Stripe.CustomerUpdateParams>) {
return await stripe.customers.update(customerId, data);
}
// ============================================
// PAYMENT METHODS
// ============================================
export async function listPaymentMethods(customerId: string) {
return await stripe.paymentMethods.list({
customer: customerId,
type: "card",
});
}
export async function attachPaymentMethod(paymentMethodId: string, customerId: string) {
return await stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
}
export async function setDefaultPaymentMethod(customerId: string, paymentMethodId: string) {
return await stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
// ============================================
// INVOICES
// ============================================
export async function listInvoices(customerId: string, limit: number = 24) {
return await stripe.invoices.list({
customer: customerId,
limit,
});
}
export async function getInvoice(invoiceId: string) {
return await stripe.invoices.retrieve(invoiceId);
}
export async function getUpcomingInvoice(customerId: string) {
return await stripe.invoices.retrieveUpcoming({
customer: customerId,
});
}
// ============================================
// PAYMENTS & REFUNDS
// ============================================
export async function createPaymentIntent(options: {
amount: number;
currency?: string;
customerId?: string;
metadata?: Record<string, string>;
}) {
return await stripe.paymentIntents.create({
amount: options.amount,
currency: options.currency || "usd",
customer: options.customerId,
metadata: options.metadata,
automatic_payment_methods: {
enabled: true,
},
});
}
export async function createRefund(paymentIntentId: string, amount?: number, reason?: string) {
return await stripe.refunds.create({
payment_intent: paymentIntentId,
amount,
reason: reason as Stripe.RefundCreateParams.Reason || "requested_by_customer",
});
}
// ============================================
// WEBHOOK PROCESSING
// ============================================
export async function processWebhook(payload: Buffer, signature: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
try {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object);
break;
case "customer.subscription.created":
await handleSubscriptionCreated(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.payment_succeeded":
await handleInvoicePaymentSucceeded(event.data.object);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object);
break;
case "customer.updated":
await handleCustomerUpdated(event.data.object);
break;
}
return { received: true };
} catch (error) {
console.error("Webhook processing error:", error);
throw error;
}
}
// Webhook handlers
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const { brand_id, type } = session.metadata || {};
if (type === "addon") {
// Handle addon checkout
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await enableAddonFeature(brand_id, subscription.metadata.addon_key);
} else {
// Handle plan checkout
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
subscription.metadata.plan_tier,
subscription.current_period_end
);
}
}
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
const { brand_id, plan_tier } = subscription.metadata;
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
plan_tier,
subscription.current_period_end
);
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const { brand_id, plan_tier, addon_key } = subscription.metadata;
if (addon_key) {
// Add-on subscription
if (subscription.status === "active") {
await enableAddonFeature(brand_id, addon_key);
} else {
await disableAddonFeature(brand_id, addon_key);
}
} else {
// Plan subscription
await updateBrandSubscription(
brand_id,
subscription.id,
subscription.status,
plan_tier,
subscription.current_period_end
);
}
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const { brand_id, plan_tier } = subscription.metadata;
// Downgrade to free tier or cancel
await updateBrandSubscription(
brand_id,
null,
"cancelled",
null,
null
);
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
// Log successful payment
console.log(`Payment succeeded for customer: ${invoice.customer}`);
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
// Notify brand about failed payment
console.log(`Payment failed for customer: ${invoice.customer}`);
// You could trigger an email notification here
// await sendPaymentFailedNotification(invoice.customer as string);
}
async function handleCustomerUpdated(customer: Stripe.Customer) {
// Sync customer data with our database
console.log(`Customer updated: ${customer.id}`);
}
// ============================================
// DATABASE HELPERS
// ============================================
async function updateBrandSubscription(
brandId: string | undefined,
subscriptionId: string | null,
status: string,
planTier: string | undefined,
periodEnd: number | null
) {
if (!brandId) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_subscription`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_subscription_id: subscriptionId,
p_subscription_status: status,
p_plan_tier: planTier,
p_current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null,
}),
}
);
} catch (error) {
console.error("Failed to update brand subscription:", error);
}
}
async function enableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: true,
}),
}
);
} catch (error) {
console.error("Failed to enable addon feature:", error);
}
}
async function disableAddonFeature(brandId: string | undefined, addonKey: string | undefined) {
if (!brandId || !addonKey) return;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
try {
await fetch(
`${supabaseUrl}/rest/v1/rpc/set_brand_feature`,
{
method: "POST",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: brandId,
p_feature_key: addonKey,
p_enabled: false,
}),
}
);
} catch (error) {
console.error("Failed to disable addon feature:", error);
}
}
// ============================================
// USAGE BILLING
// ============================================
export async function createUsageRecord(options: {
subscriptionItemId: string;
quantity: number;
timestamp?: number;
idempotencyKey?: string;
}) {
return await stripe.subscriptionItems.createUsageRecord(
options.subscriptionItemId,
{
quantity: options.quantity,
timestamp: options.timestamp,
},
{
idempotencyKey: options.idempotencyKey || uuidv4(),
}
);
}
export async function getUsageSummary(subscriptionItemId: string, period: { start: number; end: number }) {
return await stripe.usageRecordSummaries.list(
subscriptionItemId,
{
limit: 12,
}
);
}
// ============================================
// EXPORT STRIPE INSTANCE AND HELPERS
// ============================================
export { stripe };
export async function getSubscription(subscriptionId: string) {
return await stripe.subscriptions.retrieve(subscriptionId);
}
export async function listSubscriptions(customerId: string) {
return await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 10,
});
}
+337
View File
@@ -0,0 +1,337 @@
// Zod schemas for API validation across the application
import { z } from "zod";
// Common validation patterns
const uuidSchema = z.string().uuid();
const emailSchema = z.string().email();
const phoneSchema = z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number");
const urlSchema = z.string().url().optional();
// Pagination
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
});
// Brand/Admin User
export const brandIdSchema = z.object({
brand_id: z.string().uuid().optional(),
});
// Orders
export const createOrderSchema = z.object({
brand_id: uuidSchema,
customer_email: emailSchema.optional(),
customer_name: z.string().min(1).max(255).optional(),
customer_phone: phoneSchema.optional(),
customer_address: z.string().optional(),
notes: z.string().max(1000).optional(),
items: z.array(z.object({
product_id: uuidSchema,
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: uuidSchema.optional(),
});
export const updateOrderSchema = z.object({
order_id: uuidSchema,
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
notes: z.string().max(1000).optional(),
tracking_number: z.string().optional(),
payment_status: z.enum(["pending", "paid", "refunded", "failed"]).optional(),
});
export const orderFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["pending", "confirmed", "preparing", "ready", "completed", "cancelled"]).optional(),
fulfillment: z.enum(["pickup", "ship", "mixed"]).optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
customer_email: emailSchema.optional(),
stop_id: uuidSchema.optional(),
});
// Products
export const createProductSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
price: z.number().positive(),
unit: z.string().min(1).max(50).optional(),
category: z.string().max(100).optional(),
sku: z.string().max(100).optional(),
is_active: z.boolean().default(true),
is_taxable: z.boolean().default(true),
inventory_quantity: z.number().int().nonnegative().optional(),
image_url: urlSchema.optional(),
});
export const updateProductSchema = z.object({
product_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
description: z.string().max(2000).optional(),
price: z.number().positive().optional(),
unit: z.string().min(1).max(50).optional(),
category: z.string().max(100).optional(),
sku: z.string().max(100).optional(),
is_active: z.boolean().optional(),
is_taxable: z.boolean().optional(),
inventory_quantity: z.number().int().nonnegative().optional(),
image_url: urlSchema.optional(),
});
export const productFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
category: z.string().optional(),
is_active: z.boolean().optional(),
search: z.string().optional(),
min_price: z.number().positive().optional(),
max_price: z.number().positive().optional(),
});
// Stops
export const createStopSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
address: z.string().min(1),
city: z.string().max(100).optional(),
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
country: z.string().max(100).optional(),
scheduled_at: z.string().datetime(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
});
export const updateStopSchema = z.object({
stop_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
address: z.string().min(1).optional(),
city: z.string().max(100).optional(),
state: z.string().max(50).optional(),
postal_code: z.string().max(20).optional(),
scheduled_at: z.string().datetime().optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
notes: z.string().max(1000).optional(),
product_ids: z.array(uuidSchema).optional(),
});
export const stopFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
status: z.enum(["scheduled", "in_progress", "completed", "cancelled"]).optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
});
// Communication Campaigns
export const createCampaignSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
subject: z.string().min(1).max(500),
template_id: uuidSchema.optional(),
content: z.string().min(1),
type: z.enum(["email", "sms"]).default("email"),
segment_id: uuidSchema.optional(),
contact_ids: z.array(uuidSchema).optional(),
scheduled_at: z.string().datetime().optional(),
});
export const updateCampaignSchema = z.object({
campaign_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1).optional(),
status: z.enum(["draft", "scheduled", "sending", "sent", "cancelled"]).optional(),
scheduled_at: z.string().datetime().optional(),
});
// Communication Contacts
export const createContactSchema = z.object({
brand_id: uuidSchema,
email: emailSchema,
phone: phoneSchema.optional(),
first_name: z.string().max(100).optional(),
last_name: z.string().max(100).optional(),
company: z.string().max(255).optional(),
tags: z.array(z.string().max(50)).optional(),
email_opt_in: z.boolean().default(true),
sms_opt_in: z.boolean().default(false),
});
export const updateContactSchema = z.object({
contact_id: uuidSchema,
email: emailSchema.optional(),
phone: phoneSchema.optional(),
first_name: z.string().max(100).optional(),
last_name: z.string().max(100).optional(),
company: z.string().max(255).optional(),
tags: z.array(z.string().max(50)).optional(),
email_opt_in: z.boolean().optional(),
sms_opt_in: z.boolean().optional(),
notes: z.string().max(2000).optional(),
});
// Communication Templates
export const createTemplateSchema = z.object({
brand_id: uuidSchema,
name: z.string().min(1).max(255),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1),
type: z.enum(["email", "sms"]).default("email"),
category: z.string().max(100).optional(),
});
export const updateTemplateSchema = z.object({
template_id: uuidSchema,
name: z.string().min(1).max(255).optional(),
subject: z.string().min(1).max(500).optional(),
content: z.string().min(1).optional(),
category: z.string().max(100).optional(),
is_active: z.boolean().optional(),
});
// Water Log
export const createWaterLogSchema = z.object({
brand_id: uuidSchema,
field_id: uuidSchema.optional(),
field_name: z.string().max(255).optional(),
gallons: z.number().positive(),
duration_minutes: z.number().int().nonnegative().optional(),
notes: z.string().max(500).optional(),
logged_at: z.string().datetime().optional(),
});
export const updateWaterLogSchema = z.object({
log_id: uuidSchema,
gallons: z.number().positive().optional(),
duration_minutes: z.number().int().nonnegative().optional(),
notes: z.string().max(500).optional(),
});
export const waterLogFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
field_id: uuidSchema.optional(),
date_from: z.string().datetime().optional(),
date_to: z.string().datetime().optional(),
});
// Wholesale
export const createWholesaleOrderSchema = z.object({
brand_id: uuidSchema,
customer_id: uuidSchema,
items: z.array(z.object({
product_id: uuidSchema,
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1),
pickup_stop_id: uuidSchema.optional(),
notes: z.string().max(1000).optional(),
deposit_amount: z.number().nonnegative().optional(),
payment_method: z.enum(["stripe", "square", "invoice"]).default("invoice"),
});
export const updateWholesaleCustomerSchema = z.object({
customer_id: uuidSchema,
company_name: z.string().max(255).optional(),
contact_name: z.string().max(255).optional(),
email: emailSchema.optional(),
phone: phoneSchema.optional(),
credit_limit: z.number().nonnegative().optional(),
payment_terms: z.enum(["prepay", "net15", "net30", "net60"]).optional(),
is_active: z.boolean().optional(),
});
// Billing / Subscriptions
export const createSubscriptionSchema = z.object({
brand_id: uuidSchema,
plan_tier: z.enum(["starter", "farm", "enterprise"]),
interval: z.enum(["monthly", "annual"]).default("monthly"),
payment_method_id: z.string().optional(),
});
export const updateSubscriptionSchema = z.object({
subscription_id: z.string(),
plan_tier: z.enum(["starter", "farm", "enterprise"]).optional(),
interval: z.enum(["monthly", "annual"]).optional(),
status: z.enum(["active", "cancelled", "past_due", "trialing"]).optional(),
});
// Admin Users
export const createAdminUserSchema = z.object({
email: emailSchema,
role: z.enum(["platform_admin", "brand_admin", "store_employee"]),
brand_id: uuidSchema.optional(),
can_manage_products: z.boolean().default(false),
can_manage_stops: z.boolean().default(false),
can_manage_orders: z.boolean().default(false),
can_manage_pickup: z.boolean().default(false),
can_manage_messages: z.boolean().default(false),
can_manage_refunds: z.boolean().default(false),
can_manage_users: z.boolean().default(false),
can_manage_water_log: z.boolean().default(false),
can_manage_reports: z.boolean().default(false),
can_manage_settings: z.boolean().default(false),
});
export const updateAdminUserSchema = z.object({
user_id: uuidSchema,
role: z.enum(["platform_admin", "brand_admin", "store_employee"]).optional(),
can_manage_products: z.boolean().optional(),
can_manage_stops: z.boolean().optional(),
can_manage_orders: z.boolean().optional(),
can_manage_pickup: z.boolean().optional(),
can_manage_messages: z.boolean().optional(),
can_manage_refunds: z.boolean().optional(),
can_manage_users: z.boolean().optional(),
can_manage_water_log: z.boolean().optional(),
can_manage_reports: z.boolean().optional(),
can_manage_settings: z.boolean().optional(),
active: z.boolean().optional(),
});
// Reports
export const reportFiltersSchema = z.object({
brand_id: uuidSchema.optional(),
start_date: z.string().datetime(),
end_date: z.string().datetime(),
group_by: z.enum(["day", "week", "month"]).default("day"),
});
// Feature Flags
export const featureToggleSchema = z.object({
brand_id: uuidSchema,
feature_key: z.string().min(1),
enabled: z.boolean(),
});
// Referral
export const referralSchema = z.object({
referrer_id: uuidSchema,
referral_code: z.string().min(6).max(50),
referred_email: emailSchema,
campaign_id: z.string().optional(),
});
// Cart
export const cartItemSchema = z.object({
product_id: uuidSchema,
quantity: z.number().int().positive(),
fulfillment: z.enum(["pickup", "ship"]).default("pickup"),
stop_id: uuidSchema.optional(),
});
// Checkout
export const checkoutSchema = z.object({
items: z.array(cartItemSchema),
customer_email: emailSchema,
customer_name: z.string().min(1).max(255),
customer_phone: phoneSchema.optional(),
customer_address: z.string().optional(),
notes: z.string().max(1000).optional(),
payment_method: z.enum(["stripe", "square"]).default("stripe"),
promo_code: z.string().optional(),
});