fix: react-doctor unused-file 123→27 (-96 dead files removed)
This commit is contained in:
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Service-layer admin user creation. Hits the `admin_users` table directly
|
||||
* via the shared pg pool. Returns the inserted row (or existing row if the
|
||||
* user was already provisioned).
|
||||
*/
|
||||
export async function createAdminUser(
|
||||
userId: string,
|
||||
role: string,
|
||||
brandId: string | null
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const { pool } = await import("@/lib/db");
|
||||
const body = {
|
||||
user_id: userId,
|
||||
role,
|
||||
brand_id: brandId,
|
||||
active: true,
|
||||
can_manage_products: role === "platform_admin",
|
||||
can_manage_stops: role === "platform_admin",
|
||||
can_manage_orders: true,
|
||||
can_manage_pickup: role !== "store_employee",
|
||||
can_manage_messages: role === "platform_admin",
|
||||
can_manage_refunds: role === "platform_admin",
|
||||
can_manage_users: role === "platform_admin",
|
||||
can_manage_water_log: role === "platform_admin",
|
||||
can_manage_reports: role === "platform_admin",
|
||||
can_manage_settings: role === "platform_admin",
|
||||
must_change_password: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<Record<string, unknown>>(
|
||||
`INSERT INTO admin_users
|
||||
(user_id, role, brand_id, active,
|
||||
can_manage_products, can_manage_stops, can_manage_orders,
|
||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
||||
can_manage_settings, must_change_password)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role,
|
||||
brand_id = EXCLUDED.brand_id,
|
||||
active = EXCLUDED.active,
|
||||
can_manage_products = EXCLUDED.can_manage_products,
|
||||
can_manage_stops = EXCLUDED.can_manage_stops,
|
||||
can_manage_orders = EXCLUDED.can_manage_orders,
|
||||
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
||||
can_manage_messages = EXCLUDED.can_manage_messages,
|
||||
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
||||
can_manage_users = EXCLUDED.can_manage_users,
|
||||
can_manage_water_log = EXCLUDED.can_manage_water_log,
|
||||
can_manage_reports = EXCLUDED.can_manage_reports,
|
||||
can_manage_settings = EXCLUDED.can_manage_settings
|
||||
RETURNING *`,
|
||||
[
|
||||
body.user_id,
|
||||
body.role,
|
||||
body.brand_id,
|
||||
body.active,
|
||||
body.can_manage_products,
|
||||
body.can_manage_stops,
|
||||
body.can_manage_orders,
|
||||
body.can_manage_pickup,
|
||||
body.can_manage_messages,
|
||||
body.can_manage_refunds,
|
||||
body.can_manage_users,
|
||||
body.can_manage_water_log,
|
||||
body.can_manage_reports,
|
||||
body.can_manage_settings,
|
||||
body.must_change_password,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import type { AdminUser } from "@/lib/admin-permissions-types";
|
||||
|
||||
export type AuthGuardResult =
|
||||
| { authorized: true; adminUser: AdminUser }
|
||||
| { authorized: false; adminUser: null };
|
||||
|
||||
/**
|
||||
* Require an authenticated admin user. Returns a discriminated result so
|
||||
* callers can branch without throwing. Use this in API route handlers that
|
||||
* need a uniform "is this request authenticated?" check before proceeding.
|
||||
*/
|
||||
export async function requireAdminUser(): Promise<AuthGuardResult> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) {
|
||||
return { authorized: false, adminUser: null };
|
||||
}
|
||||
return { authorized: true, adminUser };
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
// Route Commerce Platform — Billing Logic
|
||||
// Central helpers for subscription creation, status sync, and feature management
|
||||
|
||||
import "server-only";
|
||||
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { ADDONS, type PlanTierKey, type AddonKey } from "./pricing";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
// ── Subscription status types ──────────────────────────────────────────────────
|
||||
|
||||
export type SubscriptionStatus =
|
||||
| "active"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "trialing"
|
||||
| "incomplete"
|
||||
| "unpaid";
|
||||
|
||||
export interface BrandSubscription {
|
||||
brand_id: string;
|
||||
stripe_subscription_id: string | null;
|
||||
stripe_subscription_status: SubscriptionStatus | null;
|
||||
stripe_current_period_end: string | null;
|
||||
stripe_customer_id: string | null;
|
||||
plan_tier: PlanTierKey;
|
||||
}
|
||||
|
||||
// ── RPC call helper (raw SQL via pg pool) ─────────────────────────────────────
|
||||
|
||||
async function rpc<T = Record<string, unknown>>(
|
||||
fn: string,
|
||||
params: ReadonlyArray<unknown>
|
||||
): Promise<T> {
|
||||
const placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const { rows } = await pool.query<Record<string, unknown>>(
|
||||
`SELECT * FROM ${fn}(${placeholders})`,
|
||||
params as unknown[]
|
||||
);
|
||||
return rows as unknown as T;
|
||||
}
|
||||
|
||||
// ── Feature sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
// When a subscription changes, sync the plan tier + all add-on feature flags
|
||||
export async function syncSubscriptionFeatures(
|
||||
brandId: string,
|
||||
subscriptionItems: Array<{ priceId: string; enabled: boolean }>
|
||||
): Promise<void> {
|
||||
// Determine plan tier from subscription items
|
||||
const priceToTier: Record<string, PlanTierKey> = {
|
||||
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
|
||||
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
|
||||
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
|
||||
};
|
||||
|
||||
const priceToAddon: Record<string, AddonKey> = {
|
||||
[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",
|
||||
};
|
||||
|
||||
// Update plan tier
|
||||
const tierItem = subscriptionItems.find((item) => priceToTier[item.priceId]);
|
||||
if (tierItem) {
|
||||
const newTier = priceToTier[tierItem.priceId];
|
||||
if (newTier) {
|
||||
await rpc("update_brand_plan_tier", [brandId, newTier]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync add-on features (parallelize per-addon RPC calls)
|
||||
await Promise.all(
|
||||
Object.entries(priceToAddon).map(async ([priceId, addonKey]) => {
|
||||
if (!priceId) return;
|
||||
try {
|
||||
const item = subscriptionItems.find((i) => i.priceId === priceId);
|
||||
const enabled = item?.enabled ?? false;
|
||||
await rpc("set_brand_feature", [brandId, addonKey, enabled]);
|
||||
} catch (err) {
|
||||
// One add-on RPC failure must not block the others from syncing.
|
||||
console.warn("[billing] set_brand_feature failed for", addonKey, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ── Subscription creation ─────────────────────────────────────────────────────
|
||||
|
||||
export async function createOrUpdateSubscription(
|
||||
brandId: string,
|
||||
priceKey: string,
|
||||
billingCycle: "monthly" | "annual"
|
||||
): Promise<{ subscriptionId: string; clientSecret: string }> {
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
|
||||
|
||||
// Get brand's Stripe customer
|
||||
const brandRows = await rpc<Array<BrandSubscription>>(
|
||||
"get_brand_subscription",
|
||||
[brandId]
|
||||
);
|
||||
const brandData = brandRows[0];
|
||||
const customerId = brandData?.stripe_customer_id;
|
||||
|
||||
if (!customerId) throw new Error("No Stripe customer for brand. Complete Stripe setup first.");
|
||||
|
||||
// Get price ID
|
||||
const priceId = getPriceId(priceKey, billingCycle);
|
||||
if (!priceId) throw new Error(`No price configured for ${priceKey} (${billingCycle})`);
|
||||
|
||||
// Check if brand already has an active subscription — update it instead of creating new
|
||||
const existingSubId = brandData?.stripe_subscription_id;
|
||||
|
||||
let subscription;
|
||||
if (existingSubId) {
|
||||
// Add or update the subscription item
|
||||
const existing = await stripe.subscriptions.retrieve(existingSubId);
|
||||
const existingItem = existing.items.data.find((item) => {
|
||||
const planPrices = [
|
||||
process.env.STRIPE_PRICE_STARTER ?? "",
|
||||
process.env.STRIPE_PRICE_FARM ?? "",
|
||||
process.env.STRIPE_PRICE_ENTERPRISE ?? "",
|
||||
];
|
||||
return planPrices.includes(item.price.id);
|
||||
});
|
||||
|
||||
if (existingItem) {
|
||||
subscription = await stripe.subscriptions.update(existingSubId, {
|
||||
items: [{ id: existingItem.id, price: priceId }],
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
} else {
|
||||
subscription = await stripe.subscriptions.update(existingSubId, {
|
||||
items: [{ price: priceId }],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
subscription = await stripe.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: "default_incomplete",
|
||||
payment_settings: { save_default_payment_method: "on_subscription" },
|
||||
expand: ["latest_invoice.payment_intent"],
|
||||
metadata: { brand_id: brandId, price_key: priceKey, billing: billingCycle },
|
||||
});
|
||||
}
|
||||
|
||||
// Save subscription ID to brand
|
||||
const subData = subscription as unknown as { id: string; status: string; current_period_end: number };
|
||||
await rpc("set_brand_subscription", [
|
||||
brandId,
|
||||
subData.id,
|
||||
subData.status,
|
||||
new Date(subData.current_period_end * 1000).toISOString(),
|
||||
]);
|
||||
|
||||
const latestInvoice = subscription.latest_invoice as { payment_intent?: { client_secret?: string } } | null;
|
||||
const invoiceOrNull = typeof subscription.latest_invoice !== "string" ? latestInvoice : null;
|
||||
const paymentIntent = invoiceOrNull?.payment_intent as { client_secret?: string } | undefined;
|
||||
const clientSecret = paymentIntent?.client_secret ?? null;
|
||||
|
||||
return { subscriptionId: subscription.id, clientSecret: clientSecret ?? "" };
|
||||
}
|
||||
|
||||
// ── Cancel subscription ─────────────────────────────────────────────────────
|
||||
|
||||
export async function cancelBrandSubscription(
|
||||
brandId: string,
|
||||
priceKey?: string
|
||||
): Promise<void> {
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!stripeKey) throw new Error("STRIPE_SECRET_KEY not configured");
|
||||
|
||||
const brandRows = await rpc<BrandSubscription[]>("get_brand_subscription", [brandId]);
|
||||
const brand = brandRows[0];
|
||||
if (!brand?.stripe_subscription_id) throw new Error("No active subscription to cancel");
|
||||
|
||||
const Stripe = (await import("stripe")).default;
|
||||
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" });
|
||||
|
||||
if (priceKey) {
|
||||
// Cancel only a specific add-on item, not the whole subscription
|
||||
const sub = await stripe.subscriptions.retrieve(brand.stripe_subscription_id);
|
||||
const targetPriceId = getPriceId(priceKey, "monthly");
|
||||
const item = sub.items.data.find((i) => i.price.id === targetPriceId);
|
||||
if (item) {
|
||||
await stripe.subscriptions.update(brand.stripe_subscription_id, {
|
||||
items: [{ id: item.id, deleted: true }],
|
||||
proration_behavior: "none",
|
||||
});
|
||||
// Disable the feature flag
|
||||
const addonKey = getAddonKeyFromPriceKey(priceKey);
|
||||
if (addonKey) {
|
||||
await rpc("set_brand_feature", [brandId, addonKey, false]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cancel entire subscription
|
||||
await stripe.subscriptions.cancel(brand.stripe_subscription_id);
|
||||
await rpc("set_brand_subscription", [
|
||||
brandId,
|
||||
"",
|
||||
"canceled",
|
||||
null,
|
||||
]);
|
||||
// Disable all add-on features (parallelize per-addon RPC calls)
|
||||
const addonKeys = Object.keys(ADDONS) as AddonKey[];
|
||||
await Promise.all(
|
||||
addonKeys.map(async (addonKey) => {
|
||||
try {
|
||||
await rpc("set_brand_feature", [brandId, addonKey, false]);
|
||||
} catch (err) {
|
||||
// One add-on disable failure must not block the others.
|
||||
console.warn("[billing] disable addon failed for", addonKey, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Past due notification ─────────────────────────────────────────────────────
|
||||
|
||||
export async function sendPastDueNotification(brandId: string): Promise<void> {
|
||||
const brandRows = await rpc<Array<{ name: string }>>("get_brand_subscription", [brandId]);
|
||||
const brand = brandRows[0];
|
||||
const brandName = brand?.name ?? "your brand";
|
||||
|
||||
const adminEmail = await getAdminEmail(brandId);
|
||||
|
||||
await rpc("enqueue_notification", [
|
||||
brandId,
|
||||
adminEmail ?? "team@cielohermosa.com",
|
||||
`Payment Failed — ${brandName}`,
|
||||
`
|
||||
<h2>Payment Failed</h2>
|
||||
<p>We were unable to charge your card for the Cielo Hermosa platform subscription.</p>
|
||||
<p>Please update your payment method within 7 days to avoid service interruption.</p>
|
||||
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing">Update Payment Method →</a></p>
|
||||
`,
|
||||
`Payment failed for ${brandName}. Please update your payment method at ${process.env.NEXT_PUBLIC_SITE_URL}/admin/settings/billing to avoid service interruption.`,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getPriceId(priceKey: string, billingCycle: "monthly" | "annual"): string | null {
|
||||
if (billingCycle === "annual") {
|
||||
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}_ANNUAL`] ?? null;
|
||||
}
|
||||
return process.env[`STRIPE_PRICE_${priceKey.toUpperCase()}`] ?? null;
|
||||
}
|
||||
|
||||
function getAddonKeyFromPriceKey(priceKey: string): AddonKey | null {
|
||||
const map: Record<string, AddonKey> = {
|
||||
harvest_reach: "harvest_reach",
|
||||
wholesale_portal: "wholesale_portal",
|
||||
water_log: "water_log",
|
||||
ai_tools: "ai_tools",
|
||||
square_sync: "square_sync",
|
||||
sms_campaigns: "sms_campaigns",
|
||||
};
|
||||
return map[priceKey] ?? null;
|
||||
}
|
||||
|
||||
async function getAdminEmail(brandId: string): Promise<string | null> {
|
||||
try {
|
||||
const data = await rpc<{ notification_email?: string; from_email?: string }>(
|
||||
"get_wholesale_settings",
|
||||
[brandId]
|
||||
);
|
||||
const settings = Array.isArray(data) ? data[0] : data;
|
||||
return settings?.notification_email ?? settings?.from_email ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Price ID resolution (for webhook) ────────────────────────────────────────
|
||||
|
||||
export function resolvePriceKeyFromPriceId(priceId: string): { type: "plan" | "addon"; key: string } | null {
|
||||
const planMap: Record<string, string> = {
|
||||
[process.env.STRIPE_PRICE_STARTER ?? ""]: "starter",
|
||||
[process.env.STRIPE_PRICE_FARM ?? ""]: "farm",
|
||||
[process.env.STRIPE_PRICE_ENTERPRISE ?? ""]: "enterprise",
|
||||
};
|
||||
if (planMap[priceId]) return { type: "plan", key: planMap[priceId] };
|
||||
|
||||
const addonMap: Record<string, AddonKey> = {
|
||||
[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",
|
||||
};
|
||||
if (addonMap[priceId]) return { type: "addon", key: addonMap[priceId] };
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Data service for direct PostgreSQL queries via pg Pool
|
||||
// This replaces the Supabase client for production use
|
||||
|
||||
import { pool } from "./db";
|
||||
import type { QueryResultRow } from "pg";
|
||||
|
||||
// Re-export pool for convenience
|
||||
export { pool };
|
||||
|
||||
// Query helpers for common operations
|
||||
export async function query<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = []
|
||||
): Promise<T[]> {
|
||||
const result = await pool.query<T>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function queryOne<T extends QueryResultRow = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = []
|
||||
): Promise<T | null> {
|
||||
const result = await pool.query<T>(sql, params);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
// Brand operations
|
||||
export async function getBrands() {
|
||||
return query<{ id: string; name: string; slug: string; accent_color: string | null; active: boolean }>(
|
||||
"SELECT id, name, slug, accent_color, active FROM brands ORDER BY name"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrandById(brandId: string) {
|
||||
return queryOne<{ id: string; name: string; slug: string }>(
|
||||
"SELECT id, name, slug FROM brands WHERE id = $1",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
|
||||
// Product operations
|
||||
export async function getProducts(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM products WHERE brand_id = $1 AND active = true AND deleted_at IS NULL ORDER BY name",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM products WHERE active = true AND deleted_at IS NULL ORDER BY name"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProductById(productId: string) {
|
||||
return queryOne<Record<string, unknown>>(
|
||||
"SELECT * FROM products WHERE id = $1 AND deleted_at IS NULL",
|
||||
[productId]
|
||||
);
|
||||
}
|
||||
|
||||
// Stop operations
|
||||
export async function getStops(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM stops WHERE brand_id = $1 AND active = true ORDER BY date DESC",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM stops WHERE active = true ORDER BY date DESC"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getStopById(stopId: string) {
|
||||
return queryOne<Record<string, unknown>>(
|
||||
"SELECT * FROM stops WHERE id = $1",
|
||||
[stopId]
|
||||
);
|
||||
}
|
||||
|
||||
// Order operations
|
||||
export async function getOrders(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.brand_id = $1
|
||||
ORDER BY o.created_at DESC`,
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
`SELECT o.*, s.city, s.state, s.date, s.location, s.slug
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
ORDER BY o.created_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
// Worker operations (for time tracking)
|
||||
export async function getWorkers(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM workers WHERE brand_id = $1 AND is_active = true ORDER BY name",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM workers WHERE is_active = true ORDER BY name"
|
||||
);
|
||||
}
|
||||
|
||||
// Task operations (for time tracking)
|
||||
export async function getTasks(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM tasks WHERE brand_id = $1 ORDER BY sort_order",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM tasks ORDER BY sort_order"
|
||||
);
|
||||
}
|
||||
|
||||
// Customer operations
|
||||
export async function getCustomers(brandId?: string | null) {
|
||||
if (brandId) {
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM customers WHERE brand_id = $1 ORDER BY name",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
return query<Record<string, unknown>>(
|
||||
"SELECT * FROM customers ORDER BY name"
|
||||
);
|
||||
}
|
||||
|
||||
// Brand settings
|
||||
export async function getBrandSettings(brandId: string) {
|
||||
return queryOne<Record<string, unknown>>(
|
||||
"SELECT * FROM brand_settings WHERE brand_id = $1",
|
||||
[brandId]
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
// FedEx OAuth token cache.
|
||||
// This module intentionally lives OUTSIDE any "use server" file. The
|
||||
// `react-doctor/server-no-mutable-module-state` rule flags module-scope
|
||||
// mutable state inside "use server" files because it can leak per-user
|
||||
// state across requests. The FedEx token cache is NOT per-user state —
|
||||
// it's a service-credential cache shared across all requests, which is
|
||||
// the desired behavior. Co-locating the cache in a non-server file keeps
|
||||
// the rule satisfied while preserving the cross-request token reuse that
|
||||
// FedEx's rate-limited OAuth endpoint requires.
|
||||
|
||||
type FedExAuthToken = {
|
||||
accessToken: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||
|
||||
export function getFedExApiBaseUrl(useProduction: boolean): string {
|
||||
return useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
}
|
||||
|
||||
// Buffer before expiry to refresh proactively
|
||||
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
let cachedToken: FedExAuthToken | null = null;
|
||||
|
||||
export async function getFedExAuthToken(settings: {
|
||||
fedexApiKey: string;
|
||||
fedexApiSecret: string;
|
||||
useProduction: boolean;
|
||||
}): Promise<{ accessToken: string } | { error: string }> {
|
||||
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||
|
||||
if (cachedToken && Date.now() < cachedToken.expiresAt - EXPIRY_BUFFER_MS) {
|
||||
return { accessToken: cachedToken.accessToken };
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(
|
||||
`${settings.fedexApiKey}:${settings.fedexApiSecret}`
|
||||
).toString("base64");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
return { error: `FedEx auth failed: ${text}` };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
cachedToken = {
|
||||
accessToken: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
} catch (err) {
|
||||
return { error: `FedEx auth network error: ${(err as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only helper to reset the cache between unit tests.
|
||||
export function __resetFedExTokenCacheForTests(): void {
|
||||
cachedToken = null;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Square OAuth token-exchange helper.
|
||||
*
|
||||
* Extracted from the `/api/square/oauth/callback` route so the GET handler
|
||||
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
|
||||
* writes live in the route file. The OAuth `code` is single-use at the
|
||||
* provider, which makes this write idempotent on replay.
|
||||
*/
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type SquareTokenExchangeResult =
|
||||
| { ok: true; accessToken: string; locationId: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function exchangeSquareCodeForToken(args: {
|
||||
code: string;
|
||||
origin: string;
|
||||
}): Promise<SquareTokenExchangeResult> {
|
||||
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
|
||||
const appSecret = process.env.SQUARE_APP_SECRET;
|
||||
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
return { ok: false, error: "square_credentials_not_configured" };
|
||||
}
|
||||
|
||||
const tokenUrl =
|
||||
env === "production"
|
||||
? "https://connect.squareup.com/v2/oauth2/token"
|
||||
: "https://connect.squareupsandbox.com/v2/oauth2/token";
|
||||
|
||||
const redirectUri = `${args.origin}/api/square/oauth/callback`;
|
||||
|
||||
const tokenResponse = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Square-Version": "2025-01-16",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
code: args.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
|
||||
access_token?: string;
|
||||
location_id?: string;
|
||||
};
|
||||
|
||||
if (!tokenResponse.ok || !tokenData.access_token) {
|
||||
return { ok: false, error: "square_token_exchange_failed" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
accessToken: tokenData.access_token,
|
||||
locationId: tokenData.location_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistSquareToken(args: {
|
||||
brandId: string;
|
||||
accessToken: string;
|
||||
locationId: string | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
[
|
||||
args.brandId,
|
||||
"square",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
args.accessToken,
|
||||
args.locationId,
|
||||
null,
|
||||
null,
|
||||
]
|
||||
);
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "square_token_save_error" };
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Stripe OAuth token-exchange helper.
|
||||
*
|
||||
* Extracted from the `/api/stripe/oauth/callback` route so the GET handler
|
||||
* stays a thin redirect layer — no `fetch(..., { method: "POST" })` or DB
|
||||
* writes live in the route file. The OAuth `code` is single-use at the
|
||||
* provider, which makes this write idempotent on replay.
|
||||
*/
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
|
||||
export type StripeTokenExchangeResult =
|
||||
| {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
publishableKey: string | null;
|
||||
stripeUserId: string;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function exchangeStripeCodeForToken(args: {
|
||||
code: string;
|
||||
}): Promise<StripeTokenExchangeResult> {
|
||||
const clientId = process.env.STRIPE_CLIENT_ID;
|
||||
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return { ok: false, error: "oauth_not_configured" };
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: args.code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenData = (await tokenResponse.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
access_token?: string;
|
||||
stripe_user_id?: string;
|
||||
stripe_publishable_key?: string;
|
||||
};
|
||||
|
||||
if (tokenData.error || !tokenData.access_token) {
|
||||
return {
|
||||
ok: false,
|
||||
error: tokenData.error_description || tokenData.error || "exchange_failed",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
accessToken: tokenData.access_token,
|
||||
publishableKey: tokenData.stripe_publishable_key ?? null,
|
||||
stripeUserId: tokenData.stripe_user_id ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistStripeCredentials(args: {
|
||||
brandId: string;
|
||||
accessToken: string;
|
||||
publishableKey: string | null;
|
||||
stripeUserId: string;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const result = await savePaymentSettings({
|
||||
brandId: args.brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: args.publishableKey ?? undefined,
|
||||
stripeSecretKey: args.accessToken,
|
||||
stripeUserId: args.stripeUserId,
|
||||
});
|
||||
return result.success
|
||||
? { ok: true }
|
||||
: { ok: false, error: result.error || "save_failed" };
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Route Trace data fetching utilities
|
||||
* Shared data fetching for all route-trace pages
|
||||
*/
|
||||
|
||||
import {
|
||||
getRouteTraceStats,
|
||||
getRouteTraceLots,
|
||||
getHarvestLotsReadyToHaul,
|
||||
getFieldYieldSummary,
|
||||
getInventoryByCrop,
|
||||
getRecentLotEvents,
|
||||
type RouteTraceStats,
|
||||
type HaulingLot,
|
||||
type FieldYieldSummary,
|
||||
type InventoryByCrop,
|
||||
type RecentLotEvent,
|
||||
} from "@/actions/route-trace/lots";
|
||||
|
||||
export type RouteTraceData = {
|
||||
stats: RouteTraceStats;
|
||||
lots: HaulingLot[];
|
||||
haulingLots: HaulingLot[];
|
||||
fieldYield: FieldYieldSummary[];
|
||||
inventoryByCrop: InventoryByCrop[];
|
||||
recentActivity: RecentLotEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch all route-trace data for a brand
|
||||
*/
|
||||
export async function fetchRouteTraceData(
|
||||
brandId: string
|
||||
): Promise<RouteTraceData> {
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
getRouteTraceStats(brandId),
|
||||
getRouteTraceLots(brandId),
|
||||
getHarvestLotsReadyToHaul(brandId),
|
||||
getFieldYieldSummary(brandId),
|
||||
getInventoryByCrop(brandId),
|
||||
getRecentLotEvents(brandId, 10),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: statsResult.success ? statsResult.stats : {
|
||||
active_count: 0,
|
||||
in_transit_count: 0,
|
||||
at_shed_count: 0,
|
||||
total_lots_today: 0,
|
||||
total_harvested_today: 0,
|
||||
total_lots: 0,
|
||||
},
|
||||
lots: lotsResult.success ? lotsResult.lots : [],
|
||||
haulingLots: haulingResult.success ? haulingResult.lots : [],
|
||||
fieldYield: yieldResult.success ? yieldResult.summary : [],
|
||||
inventoryByCrop: invResult.success ? invResult.inventory : [],
|
||||
recentActivity: eventsResult.success ? eventsResult.events : [],
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const YIELD_UNIT_OPTIONS = [
|
||||
{ value: "lbs", label: "lbs", abbr: "lbs" },
|
||||
{ value: "bushel", label: "Bushel", abbr: "bu" },
|
||||
{ value: "box", label: "Box", abbr: "bx" },
|
||||
{ value: "sack", label: "Sack", abbr: "sk" },
|
||||
{ value: "crate", label: "Crate", abbr: "cr" },
|
||||
{ value: "bin", label: "Bin", abbr: "bn" },
|
||||
{ value: "pallet", label: "Pallet", abbr: "plt" },
|
||||
{ value: "custom", label: "Custom", abbr: "" },
|
||||
] as const;
|
||||
export type YieldUnit = typeof YIELD_UNIT_OPTIONS[number]["value"];
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
/**
|
||||
* Render a sanitized HTML string as React children. Use this anywhere
|
||||
* `dangerouslySetInnerHTML` would otherwise be the only option (e.g.
|
||||
* email preview bodies, template bodies, etc.).
|
||||
*
|
||||
* DOMPurify is configured with a strict default profile plus a small
|
||||
* allow-list for the elements commonly used in our email templates
|
||||
* (inline styles, tables, images with http(s) sources). Custom config
|
||||
* can be passed via the second argument if needed.
|
||||
*/
|
||||
export function SafeHtml({
|
||||
html,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
html: string;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const clean = DOMPurify.sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_ATTR: [
|
||||
"href", "target", "rel", "src", "alt", "title", "style", "class",
|
||||
"id", "name", "colspan", "rowspan", "width", "height", "align",
|
||||
"valign", "bgcolor", "color", "border", "cellpadding", "cellspacing",
|
||||
"role", "aria-label", "aria-hidden",
|
||||
],
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
// The sanitizer above guarantees the result is safe to inject.
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
// 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,
|
||||
}));
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Safe service-headers for Vercel Edge Runtime.
|
||||
*
|
||||
* Supabase REST accepts `apikey` as a standalone auth header — no
|
||||
* Authorization: Bearer needed. This avoids all JWT header validation
|
||||
* issues on Vercel Edge Runtime where +, /, = cause "Headers.append:
|
||||
* ...is an invalid header value".
|
||||
*
|
||||
* The apikey header alone is sufficient for service-role access to
|
||||
* the Supabase REST API.
|
||||
*/
|
||||
export function svcHeaders(serviceKey: string): Record<string, string> {
|
||||
return {
|
||||
apikey: serviceKey,
|
||||
};
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
// Zod schemas for API validation across the application
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// Common validation patterns
|
||||
const uuidSchema = z.uuid();
|
||||
const emailSchema = z.email();
|
||||
const phoneSchema = z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number");
|
||||
const urlSchema = z.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.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.iso.datetime().optional(),
|
||||
date_to: z.iso.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.iso.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.iso.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.iso.datetime().optional(),
|
||||
date_to: z.iso.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.iso.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.iso.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.iso.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.iso.datetime().optional(),
|
||||
date_to: z.iso.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.iso.datetime(),
|
||||
end_date: z.iso.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(),
|
||||
});
|
||||
Reference in New Issue
Block a user