fix: react-doctor errors → 0 errors, 1649 warnings (46/100)

- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
+3 -1
View File
@@ -1,7 +1,9 @@
"use server";
import { getAdminUser, type AdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
export async function getCurrentAdminUser(): Promise<AdminUser | null> {
return getAdminUser();
await getSession(); return getAdminUser();
}
+5 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
type AdminActionPayload = {
action_type: "create" | "update" | "delete";
@@ -20,7 +21,8 @@ type UserActivityPayload = {
};
export async function logAdminAction(payload: AdminActionPayload): Promise<void> {
try {
await getSession(); try {
await pool.query("SELECT log_admin_action($1::jsonb)", [
JSON.stringify({
action_type: payload.action_type,
@@ -37,7 +39,8 @@ export async function logAdminAction(payload: AdminActionPayload): Promise<void>
}
export async function logUserActivity(payload: UserActivityPayload): Promise<void> {
try {
await getSession(); try {
await pool.query("SELECT log_user_activity($1::jsonb)", [
JSON.stringify({
user_id: payload.user_id,
+2 -1
View File
@@ -17,7 +17,8 @@ export async function updatePasswordAction(
userId: string,
newPassword: string
): Promise<{ success: boolean; error?: string }> {
// Verify the caller is an admin
await getSession(); // Verify the caller is an admin
const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated. Please log in again." };
+3 -1
View File
@@ -8,6 +8,7 @@ import {
setUserPassword as neonAuthSetUserPassword,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
export type ResetAdminPasswordResult =
| { success: true; tempPassword: string; method: "set" }
@@ -34,7 +35,8 @@ export type ResetAdminPasswordResult =
export async function resetAdminPassword(
email: string,
): Promise<ResetAdminPasswordResult> {
// 1. Authz check.
await getSession(); // 1. Authz check.
const me = await getAdminUser();
if (!me) {
return { success: false, error: "Not authenticated." };
+15 -7
View File
@@ -7,6 +7,7 @@ import {
requestPasswordReset as neonAuthRequestPasswordReset,
} from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
export type AdminUserRow = {
id: string;
@@ -156,7 +157,8 @@ async function sendWelcomeEmailSafe(input: {
// ─── Public actions ─────────────────────────────────────────────────────────
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
try {
await getSession(); try {
const sql = brandId
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
au.role, au.brand_id, b.name AS brand_name,
@@ -237,7 +239,8 @@ export type CreateAdminUserResult = {
export async function createAdminUser(
input: CreateAdminUserInput,
): Promise<CreateAdminUserResult> {
// 1. Authorization: only platform admins can mint new admin users.
await getSession(); // 1. Authorization: only platform admins can mint new admin users.
const caller = await getAdminUser();
if (!caller) {
return { user: null, error: "Not authenticated. Please sign in again." };
@@ -508,7 +511,8 @@ async function signupFallbackCreate(
}
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
try {
await getSession(); try {
// Build a partial SET clause. Each `can_manage_*` column is set
// individually — the input's `flags` partial is spread across them.
const sets: string[] = [];
@@ -544,7 +548,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
}
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
try {
await getSession(); try {
// No Supabase Auth — nothing to delete from the auth service.
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
return { success: (rowCount ?? 0) > 0, error: null };
@@ -554,7 +559,8 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
}
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
try {
await getSession(); try {
const { rowCount } = await query(
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
[userId],
@@ -581,7 +587,8 @@ export async function setMustChangePassword(userId: string): Promise<{ success:
export async function sendPasswordResetEmail(
email: string,
): Promise<{ success: boolean; error: string | null }> {
try {
await getSession(); try {
// Authz: must be signed in as a platform_admin.
const me = await getAdminUser();
if (!me) {
@@ -622,7 +629,8 @@ export async function sendPasswordResetEmail(
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
try {
await getSession(); try {
// Sort by name so the platform admin's brand picker stays stable.
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM brands ORDER BY name`,
+5 -2
View File
@@ -7,6 +7,7 @@ import { importProductsBatch } from "@/actions/import-products";
import { importOrdersBatch } from "@/actions/import-orders";
import { createStopsBatch } from "@/actions/stops";
import { importContactsBatch } from "@/actions/communications/contacts";
import { getSession } from "@/lib/auth";
export type ImportEntityType = "products" | "orders" | "contacts" | "stops" | "unknown";
@@ -40,7 +41,8 @@ export async function analyzeImport(
fileName: string,
brandId: string
): Promise<AnalyzeImportResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
try {
assertBrandAccess(adminUser, brandId);
@@ -98,7 +100,8 @@ export async function executeImport(
detectedType: ImportEntityType,
rows: Record<string, unknown>[]
): Promise<ExecuteImportResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
switch (detectedType) {
+107
View File
@@ -0,0 +1,107 @@
"use server";
import { requireAuth } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
export type AIAuthConfig = {
provider: string;
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
};
export async function getAIPreferences(
brandId: string,
): Promise<{
api_key?: string;
organization_id?: string;
base_url?: string;
model?: string;
max_tokens?: number;
} | null> {
await requireAuth();
const { rows } = await pool.query<{
api_key: string;
organization_id: string;
base_url: string;
model: string;
max_tokens: number;
}>(
`SELECT api_key, organization_id, base_url, model, max_tokens
FROM brand_ai_settings
WHERE brand_id = $1
LIMIT 1`,
[brandId],
);
return rows[0] ?? null;
}
export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig,
): Promise<{ success: boolean; error?: string }> {
await requireAuth();
try {
await pool.query(
`INSERT INTO brand_ai_settings (
brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
ON CONFLICT (brand_id) DO UPDATE SET
provider = EXCLUDED.provider,
api_key = EXCLUDED.api_key,
organization_id = EXCLUDED.organization_id,
base_url = EXCLUDED.base_url,
model = EXCLUDED.model,
max_tokens = EXCLUDED.max_tokens,
updated_at = EXCLUDED.updated_at`,
[
brandId,
config.provider,
config.api_key || null,
config.organization_id || null,
config.base_url || null,
config.model || "gpt-4o-mini",
config.max_tokens || 4000,
],
);
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return { success: false, error: message };
}
}
export async function testAIConnection(
config: AIAuthConfig,
): Promise<{ ok: boolean; message: string }> {
await requireAuth();
if (!config.api_key?.trim()) {
return { ok: false, message: "API key is required" };
}
try {
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${config.api_key}`,
},
});
if (response.ok) {
return { ok: true, message: "Connection successful! Your API key is valid." };
}
if (response.status === 401) {
return { ok: false, message: "Invalid API key. Please check and try again." };
}
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
} catch {
return { ok: false, message: "Could not connect. Check your network and API key." };
}
}
+13 -6
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -109,7 +110,8 @@ async function getReportsSummary(
// ── Analytics Actions ─────────────────────────────────────────────────────────
export async function getAnalyticsMetrics(periodDays: number = 30): Promise<AnalyticsMetrics> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
@@ -173,7 +175,8 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
}
export async function getRevenueChart(periodDays: number = 30): Promise<RevenueDataPoint[]> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
@@ -210,7 +213,8 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
}
export async function getTopProducts(limit: number = 5): Promise<ProductPerformance[]> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
@@ -263,7 +267,8 @@ export async function getTopProducts(limit: number = 5): Promise<ProductPerforma
}
export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
@@ -313,7 +318,8 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
}
export async function getCustomerGrowth(): Promise<CustomerGrowth> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
@@ -358,7 +364,8 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
}
export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
try {
await getSession(); try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const brandId = await getActiveBrandId(adminUser);
+3 -1
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type AuditAction = "INSERT" | "UPDATE" | "DELETE";
@@ -26,7 +27,8 @@ type AuditResult =
* PL/pgSQL function via the shared pg pool — no Supabase REST hop.
*/
export async function logAuditEvent(payload: AuditPayload): Promise<AuditResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
const performed_by = adminUser?.user_id ?? null;
const performed_by_email =
+5 -2
View File
@@ -3,12 +3,14 @@
import "server-only";
import { signIn, signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
/**
* Sign out and clear the Neon Auth session cookie.
*/
export async function signOutAction(): Promise<void> {
console.log("[auth/sign-out] Signing out");
await getSession(); console.log("[auth/sign-out] Signing out");
await signOut();
redirect("/login");
}
@@ -28,7 +30,8 @@ export async function signInWithGoogleAction(input: {
callbackURL?: string;
errorCallbackURL?: string;
}): Promise<{ url: string | null; error: string | null }> {
const callbackURL = input.callbackURL ?? "/admin";
await getSession(); const callbackURL = input.callbackURL ?? "/admin";
const errorCallbackURL = input.errorCallbackURL ?? "/login?error=oauth";
try {
+3 -1
View File
@@ -41,6 +41,7 @@ import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq, count } from "drizzle-orm";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
import { getSession } from "@/lib/auth";
export type BillingSubscriptionStatus =
| "active"
@@ -93,7 +94,8 @@ export async function getBillingOverview(
brandId: string,
options?: { planCycle?: "monthly" | "annual" }
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
try {
await getSession(); try {
if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
+5 -3
View File
@@ -1,6 +1,7 @@
"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
type LineItem = {
id: string;
@@ -10,7 +11,7 @@ type LineItem = {
};
// Stripe API version type - using const assertion for type safety
type StripeApiVersion = "2026-05-27.dahlia";
type StripeApiVersion = "2026-06-24.dahlia";
export async function createRetailStripeCheckoutSession(
items: LineItem[],
@@ -19,11 +20,12 @@ export async function createRetailStripeCheckoutSession(
successUrl: string,
cancelUrl: string
): Promise<{ success: boolean; url?: string; sessionId?: string; error?: string }> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
await getSession(); const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) return { success: false, error: "Stripe not configured on this server." };
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const lineItems = items.map((item) => ({
price_data: {
+5 -3
View File
@@ -1,9 +1,10 @@
"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
type StripeApiVersion = "2026-06-24.dahlia";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
@@ -39,7 +40,8 @@ export async function createRetailPaymentIntent(
stopId: string | null,
shippingAddress?: { state?: string; postal_code?: string; city?: string } | null
): Promise<CreatePaymentIntentResult> {
const stripeKey = process.env.STRIPE_SECRET_KEY;
await getSession(); const stripeKey = process.env.STRIPE_SECRET_KEY;
if (!stripeKey) {
return { success: false, error: "Stripe not configured on this server." };
}
@@ -49,7 +51,7 @@ export async function createRetailPaymentIntent(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
// Compute the subtotal in cents. We don't compute sales tax here —
// Stripe's `automatic_tax` would be ideal but requires address collection
+14 -7
View File
@@ -2,14 +2,15 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// Stripe API version type - using const assertion for type safety
type StripeApiVersion = "2026-05-27.dahlia";
type StripeApiVersion = "2026-06-24.dahlia";
// ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables
const PRICE_KEYS: Record<string, string | undefined> = {
const PRICE_KEYS: Record<string, string | undefined> = Object.freeze({
starter: process.env.STRIPE_PRICE_STARTER,
farm: process.env.STRIPE_PRICE_FARM,
enterprise: process.env.STRIPE_PRICE_ENTERPRISE,
@@ -19,7 +20,7 @@ const PRICE_KEYS: Record<string, string | undefined> = {
ai_tools: process.env.STRIPE_PRICE_AI_TOOLS,
square_sync: process.env.STRIPE_PRICE_SQUARE_SYNC,
sms_campaigns: process.env.STRIPE_PRICE_SMS_CAMPAIGNS,
};
});
function getPriceId(key: string): string | null {
return PRICE_KEYS[key] ?? null;
@@ -34,6 +35,8 @@ export async function createStripeCheckoutSession(
cancelPath: string,
annual = false
): Promise<{ success: boolean; url?: string; error?: string }> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
@@ -57,7 +60,7 @@ export async function createStripeCheckoutSession(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
@@ -84,7 +87,8 @@ export async function createPlanUpgradeCheckout(
planTier: string,
billingPeriod?: "monthly" | "annual"
): Promise<{ success: boolean; url?: string; error?: string }> {
if (!["starter", "farm", "enterprise"].includes(planTier)) {
await getSession(); if (!["starter", "farm", "enterprise"].includes(planTier)) {
return { success: false, error: "Invalid plan tier" };
}
const annual = billingPeriod === "annual";
@@ -101,7 +105,8 @@ export async function createAddonCheckoutSession(
brandId: string,
addonKey: string
): Promise<{ success: boolean; url?: string; error?: string }> {
return createStripeCheckoutSession(
await getSession(); return createStripeCheckoutSession(
brandId,
addonKey,
"/admin/settings/billing",
@@ -113,6 +118,8 @@ export async function cancelAddonSubscription(
brandId: string,
addonKey: string
): Promise<{ success: boolean; error?: string }> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
@@ -136,7 +143,7 @@ export async function cancelAddonSubscription(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
// Retrieve subscription and find the item for this add-on
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
+15 -8
View File
@@ -2,9 +2,10 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
type StripeApiVersion = "2026-06-24.dahlia";
// Type for plan info response
type PlanInfo = {
@@ -27,7 +28,8 @@ type WholesaleOrder = {
};
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -48,7 +50,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
@@ -59,7 +61,8 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
}
export async function updateBrandPlanTier(brandId: string, planTier: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -80,7 +83,8 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr
}
export async function updateBrandStripeCustomerId(brandId: string, stripeCustomerId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -98,7 +102,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
}
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
// Replicate get_brand_plan_info via a JOIN on tenants + plans
await getSession(); // Replicate get_brand_plan_info via a JOIN on tenants + plans
const res = await pool.query<{
plan_tier: string;
plan_name: string | null;
@@ -132,7 +137,8 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
}
export async function getEnabledAddons(brandId: string): Promise<Record<string, boolean>> {
// get_brand_features returns JSONB — a single object, not an array
await getSession(); // get_brand_features returns JSONB — a single object, not an array
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
@@ -147,7 +153,8 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
}
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
try {
await getSession(); try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
[brandId]
+13 -6
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { uploadObject, BUCKETS } from "@/lib/storage";
import { getSession } from "@/lib/auth";
export type UploadLogoResult =
| { success: true; logoUrl: string }
@@ -18,7 +19,8 @@ export async function uploadBrandLogo(
file: File,
isDark: boolean = false
): Promise<UploadLogoResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -65,7 +67,8 @@ export async function uploadOlatheSweetLogo(
brandId: string,
file: File
): Promise<UploadLogoResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -111,7 +114,8 @@ export async function uploadOlatheSweetLogoDark(
brandId: string,
file: File
): Promise<UploadLogoResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -232,7 +236,8 @@ export type SaveBrandSettingsResult =
| { success: false; error: string };
export async function getBrandSettings(brandId: string): Promise<GetBrandSettingsResult> {
try {
await getSession(); try {
const { rows } = await pool.query<BrandSettings>(
"SELECT * FROM get_brand_settings($1)",
[brandId],
@@ -253,7 +258,8 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
// storefront would silently fall back to defaults. The inlined query
// has the same shape and never fails because of a missing RPC.
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
try {
await getSession(); try {
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
FROM brands b
@@ -312,7 +318,8 @@ export async function saveBrandSettings(params: {
collectSalesTax?: boolean;
nexusStates?: string[];
}): Promise<SaveBrandSettingsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
+13 -6
View File
@@ -1,6 +1,7 @@
"use server";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type CartItem = {
id: string;
@@ -64,7 +65,8 @@ export async function createOrder(
brandId?: string,
shippingAddress?: ShippingAddress
): Promise<CheckoutResult> {
// ── Calculate tax if brand collects tax ─────────────────────────────────
await getSession(); // ── Calculate tax if brand collects tax ─────────────────────────────────
let taxAmount = 0;
let taxRate = 0;
let taxLocation = "";
@@ -162,7 +164,8 @@ export async function createOrder(
// ── Cart Persistence ──────────────────────────────────────────────────────────
export async function getServerCart(userId: string): Promise<CartItem[]> {
try {
await getSession(); try {
const { rows } = await pool.query<{ get_user_cart: CartItem[] | null }>(
`SELECT get_user_cart($1) AS "get_user_cart"`,
[userId],
@@ -178,7 +181,8 @@ export async function mergeLocalCart(
localCart: CartItem[],
userId: string
): Promise<{ merged: CartItem[] }> {
if (!localCart || localCart.length === 0) return { merged: [] };
await getSession(); if (!localCart || localCart.length === 0) return { merged: [] };
// Fetch server cart
let serverCart: CartItem[] = [];
@@ -233,7 +237,8 @@ export async function mergeLocalCart(
}
export async function clearServerCart(userId: string): Promise<void> {
try {
await getSession(); try {
await pool.query(
`SELECT clear_user_cart($1)`,
[userId],
@@ -256,7 +261,8 @@ export type PublicStop = {
};
export async function getPublicStopsForBrand(brandId: string): Promise<PublicStop[]> {
const { rows } = await pool.query<PublicStop>(
await getSession(); const { rows } = await pool.query<PublicStop>(
`SELECT id, city, state, date, time, location, brand_id
FROM stops
WHERE active = true AND brand_id = $1
@@ -275,7 +281,8 @@ export async function checkStopProductAvailability(
stopId: string,
productIds: string[]
): Promise<ProductAvailability[]> {
if (!productIds || productIds.length === 0) return [];
await getSession(); if (!productIds || productIds.length === 0) return [];
const { rows } = await pool.query<{ check_stop_product_availability: ProductAvailability[] | null }>(
`SELECT check_stop_product_availability($1, $2::uuid[]) AS "check_stop_product_availability"`,
[stopId, productIds],
+9 -4
View File
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns, emailTemplates } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type CampaignType = "marketing" | "operational" | "transactional";
export type CampaignStatus = "draft" | "scheduled" | "sending" | "sent" | "canceled";
@@ -101,7 +102,8 @@ function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
}
export async function getCommunicationCampaigns(brandId?: string): Promise<ListCampaignsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
@@ -165,7 +167,8 @@ export async function upsertCampaign(params: {
audience_rules?: AudienceRules;
scheduled_at?: string;
}): Promise<UpsertCampaignResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
@@ -263,7 +266,8 @@ export async function upsertCampaign(params: {
}
export async function deleteCampaign(campaignId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
@@ -290,7 +294,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
}
export async function getCampaignById(campaignId: string, brandId?: string): Promise<Campaign | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
const activeBrandId = await getActiveBrandId(adminUser, brandId);
+15 -7
View File
@@ -6,6 +6,7 @@ import { parseCSVWithLimits } from "@/lib/csv-parser";
import { buildImportPreview } from "@/lib/column-detector";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { customers } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type ContactSource = "order" | "import" | "manual" | "admin";
@@ -134,7 +135,8 @@ export async function getContacts(params: {
limit?: number;
offset?: number;
}): Promise<GetContactsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
adminUser.role === "brand_admin" &&
@@ -210,7 +212,8 @@ export async function upsertContact(contact: {
tags?: string[];
metadata?: Record<string, unknown>;
}): Promise<UpsertContactResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
adminUser.role === "brand_admin" &&
@@ -327,7 +330,8 @@ export async function importContactsBatch(params: {
contacts: ContactImportEntry[];
allowOptInOverride?: boolean;
}): Promise<ImportContactsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
adminUser.role === "brand_admin" &&
@@ -385,7 +389,8 @@ export async function optOutContact(params: {
brandId: string;
method: "email" | "sms";
}): Promise<OptOutResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
adminUser.role === "brand_admin" &&
@@ -414,7 +419,8 @@ export async function optOutContact(params: {
}
export async function deleteContact(id: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
adminUser.role === "brand_admin" &&
@@ -470,7 +476,8 @@ export async function exportContacts(params: {
search?: string;
source?: string;
}): Promise<ExportContactsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const effectiveBrandId =
@@ -551,7 +558,8 @@ export async function exportContacts(params: {
export async function previewContactImport(
csvText: string
): Promise<{ success: true; preview: ImportPreviewResult } | { success: false; error: string }> {
try {
await getSession(); try {
const { csv, totalRows, warnings } = parseCSVWithLimits(csvText);
if (warnings.length > 0 && totalRows === 0) {
@@ -4,6 +4,7 @@ import { and, desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { files } from "@/db/schema";
import { getSession } from "@/lib/auth";
import {
importContactsBatch,
previewContactImport,
@@ -26,7 +27,8 @@ export async function uploadContactsToBucket(
brandId: string,
file: File
): Promise<UploadContactsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Validate file type
@@ -98,7 +100,8 @@ export async function processBucketImport(
allowOptInOverride: boolean = false,
rows?: ContactImportEntry[]
): Promise<ProcessImportResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!rows || rows.length === 0) {
@@ -126,7 +129,8 @@ export async function listImportHistory(
brandId: string,
limit: number = 10
): Promise<{ success: true; imports: ImportHistoryItem[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
try {
+7 -3
View File
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import type { AudienceRules } from "./campaigns";
import { getSession } from "@/lib/auth";
/**
* The new schema does not have a `communication_segments` table.
@@ -88,7 +89,8 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
export async function getCommunicationSegments(
brandId: string
): Promise<ListSegmentsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
@@ -113,7 +115,8 @@ export async function upsertSegment(params: {
description?: string;
rules: AudienceRules;
}): Promise<UpsertSegmentResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
@@ -164,7 +167,8 @@ export async function deleteSegment(
segmentId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
+7 -3
View File
@@ -6,6 +6,7 @@ import { getActiveBrandId } from "@/lib/brand-scope";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns, customers } from "@/db/schema";
import type { AudienceRules } from "./campaigns";
import { getSession } from "@/lib/auth";
export type AudiencePreviewResult = {
count: number;
@@ -24,7 +25,8 @@ export async function previewCampaignAudience(
brandId: string,
audienceRules: AudienceRules
): Promise<AudiencePreviewResult | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
@@ -112,7 +114,8 @@ export async function getMessageLogs(params: {
status?: string;
limit?: number;
}): Promise<GetMessageLogsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
@@ -140,7 +143,8 @@ export type SendCampaignResult = {
* status-transition that unblocks the UI.
*/
export async function sendCampaign(campaignId: string, brandId?: string): Promise<SendCampaignResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
+5 -2
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { getSession } from "@/lib/auth";
/**
* The new schema does not have a `communication_settings` table. The
@@ -46,7 +47,8 @@ function readFlag(
}
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
try {
await getSession(); try {
const rows = await withBrand(brandId, (db) =>
db
.select({
@@ -88,7 +90,8 @@ export async function upsertCommunicationSettings(params: {
provider?: string;
footer_html?: string;
}): Promise<UpsertSettingsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
+3 -1
View File
@@ -4,6 +4,7 @@ import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type StopBlastResult =
| { success: true; campaign_id: string; messages_logged: number }
@@ -29,7 +30,8 @@ export async function sendStopBlast(params: {
body: string;
audience: "all" | "pending" | "picked_up";
}): Promise<StopBlastResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brandId) {
+3 -1
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { customers, orders, campaigns } from "@/db/schema";
import { getSession } from "@/lib/auth";
/**
* Server-side data loader for the per-stop "Message customers" panel.
@@ -45,7 +46,8 @@ export async function getStopMessagingData(params: {
stopId: string;
brandId?: string;
}): Promise<GetStopMessagingDataResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// We don't filter by `stopId` because the new `orders` table has no
+7 -3
View File
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { emailTemplates } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type TemplateType = "email_template" | "sms_template" | "internal_note";
export type CampaignType = "marketing" | "operational" | "transactional";
@@ -65,7 +66,8 @@ function stripHtml(html: string): string {
}
export async function getCommunicationTemplates(brandId?: string): Promise<{ success: true; templates: Template[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const activeBrandId = await getActiveBrandId(adminUser, brandId);
@@ -111,7 +113,8 @@ export async function upsertTemplate(params: {
template_type: TemplateType;
campaign_type?: CampaignType;
}): Promise<UpsertTemplateResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const bodyHtml =
@@ -163,7 +166,8 @@ export async function upsertTemplate(params: {
}
export async function getTemplateById(templateId: string): Promise<Template | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
const activeBrandId = await getActiveBrandId(adminUser);
+9 -2
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -44,17 +45,19 @@ export type MobileDashboardSummary = {
orders_last_7_days: Array<{ date: string; count: number }>;
};
const EMPTY_MOBILE_DASHBOARD: MobileDashboardSummary = {
const EMPTY_MOBILE_DASHBOARD: Readonly<MobileDashboardSummary> = Object.freeze({
orders_today: 0,
revenue_today: 0,
pending_fulfillment: 0,
stops_today: 0,
orders_last_7_days: [],
};
});
// ── Dashboard Stats Actions ─────────────────────────────────────────────────
export async function getDashboardStats(): Promise<DashboardStats> {
await getSession();
try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
@@ -227,6 +230,8 @@ export async function getDashboardStats(): Promise<DashboardStats> {
}
export async function getDashboardSummary(): Promise<DashboardSummary> {
await getSession();
try {
const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
@@ -311,6 +316,8 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
export async function getMobileDashboardSummary(
brandId: string,
): Promise<MobileDashboardSummary> {
await getSession();
try {
const adminUser = await getAdminUser();
if (!adminUser) {
+29 -20
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
/**
* The new schema does not have an `abandoned_carts` table. The legacy
@@ -40,46 +41,48 @@ export type GetAbandonedCartsResult = {
// ── Sequence email intervals ───────────────────────────────────────────────────
const LOCALE_CART_SUBJECT: Record<string, Record<number, { subject: string; heading: string; body: string }>> = {
en: {
1: {
const LOCALE_CART_SUBJECT: Readonly<Record<string, Record<number, Readonly<{ subject: string; heading: string; body: string }>>>> = Object.freeze({
en: Object.freeze({
1: Object.freeze({
subject: "Your cart is waiting — complete your order",
heading: "Don't forget your order",
body: "You left items in your cart. Complete your order before the pickup date fills up.",
},
2: {
}),
2: Object.freeze({
subject: "Still thinking it over? Your cart is still here",
heading: "A little reminder",
body: "Your wholesale order is still waiting. Lock in your pickup date before it books up.",
},
3: {
}),
3: Object.freeze({
subject: "Last chance — your cart expires soon",
heading: "One more day to order",
body: "This is your final reminder. After this, your cart will no longer be available.",
},
},
es: {
1: {
}),
}),
es: Object.freeze({
1: Object.freeze({
subject: "Tu carrito te espera — completa tu pedido",
heading: "No olvides tu pedido",
body: "Dejaste artículos en tu carrito. Completa tu pedido antes de que se llene la fecha de recogida.",
},
2: {
}),
2: Object.freeze({
subject: "¿Aún lo estás pensando? Tu carrito sigue aquí",
heading: "Un pequeño record",
body: "Tu pedido al por mayor aún está esperando. Reserva tu fecha de recogida antes de que se llene.",
},
3: {
}),
3: Object.freeze({
subject: "Última oportunidad — tu carrito expira pronto",
heading: "Un día más para ordenar",
body: "Este es tu último recordatorio. Después de esto, tu carrito ya no estará disponible.",
},
},
};
}),
}),
});
// ── Get all carts (admin view) ─────────────────────────────────────────────────
export async function getAbandonedCarts(brandId: string): Promise<GetAbandonedCartsResult> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -103,6 +106,8 @@ export async function manuallyCloseAbandonedCart(
cartId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -182,7 +187,8 @@ export async function sendAbandonedCartEmail(
step: number,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`;
await getSession(); const adminUrl = `https://route-commerce-platform.vercel.app/admin/communications/abandoned-carts`;
const { subject, html, text } = buildCartRecoveryEmail({
brandName: cart.brand_name ?? "Our Farm",
contactName: cart.contact_name,
@@ -229,6 +235,8 @@ export async function resendAbandonedCartEmail(
cartId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -244,7 +252,8 @@ export async function resendAbandonedCartEmail(
// ── Mark cart as recovered when order is placed ────────────────────────────────
export async function markCartRecovered(cartId: string, orderId: string): Promise<void> {
void cartId;
await getSession(); void cartId;
void orderId;
// No DB call — abandoned_carts persistence is gone.
}
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
/**
* The new schema does not have a `welcome_sequence` table. The legacy
@@ -41,72 +42,74 @@ type WelcomeEmailContent = {
cta_url: string;
};
const WELCOME_EMAILS: Record<string, Record<number, WelcomeEmailContent>> = {
en: {
1: {
const WELCOME_EMAILS: Readonly<Record<string, Record<number, Readonly<WelcomeEmailContent>>>> = Object.freeze({
en: Object.freeze({
1: Object.freeze({
subject: "Welcome to {brand} — here's what to expect",
heading: "You're in!",
body: "Thanks for subscribing to {brand}'s wholesale updates. Here's what you can expect:\n\n• New product announcements before anyone else\n• Exclusive wholesale pricing\n• Seasonal availability alerts\n• Quick reorder from your saved preferences",
cta_text: "Explore wholesale catalog",
cta_url: "/wholesale",
},
2: {
}),
2: Object.freeze({
subject: "How wholesale ordering works with {brand}",
heading: "Ordering is simple",
body: "Here's how our wholesale process works:\n\n1. Browse the catalog and add items to your cart\n2. Checkout and pay online, or request an invoice\n3. Choose your pickup date at checkout\n4. We'll have your order ready when you arrive\n\nNo account required to browse — sign up only when you're ready to order.",
cta_text: "See current availability",
cta_url: "/wholesale/portal",
},
3: {
}),
3: Object.freeze({
subject: "Your first order is waiting — {brand} wholesale",
heading: "Ready to try us out?",
body: "If you've been thinking about placing your first wholesale order with {brand}, now's a great time.\n\nOur current seasonal selection includes produce from our farm and partner growers, sourced for freshness and quality.\n\nQuestions? Reply to this email — we read every message.",
cta_text: "Start my first order",
cta_url: "/wholesale/register",
},
4: {
}),
4: Object.freeze({
subject: "You're all set — wholesale updates from {brand}",
heading: "You're all set",
body: "You're now fully set up to receive wholesale updates from {brand}.\n\nWe'll send you occasional emails about new products, seasonal availability, and any special offers. No spam — just the useful stuff.\n\nYou can unsubscribe at any time.",
cta_text: "Browse the catalog",
cta_url: "/wholesale/portal",
},
},
es: {
1: {
}),
}),
es: Object.freeze({
1: Object.freeze({
subject: "Bienvenido a {brand} — esto es lo que puedes esperar",
heading: "¡Bienvenido!",
body: "Gracias por suscribirte a las actualizaciones mayoristas de {brand}. Esto es lo que puedes esperar:\n\n• Anuncios de nuevos productos antes que nadie\n• Precios exclusivos al por mayor\n• Alertas de disponibilidad por temporada\n• Reorden rápido desde tus preferencias guardadas",
cta_text: "Explorar catálogo mayorista",
cta_url: "/wholesale",
},
2: {
}),
2: Object.freeze({
subject: "Cómo funciona el pedido al por mayor con {brand}",
heading: "Ordenar es simple",
body: "Así funciona nuestro proceso mayorista:\n\n1. Explora el catálogo y agrega artículos a tu carrito\n2. Paga en línea o solicita una factura\n3. Elige tu fecha de recogida al pagar\n4. Tendremos tu pedido listo cuando llegues\n\nNo necesitas cuenta para浏览 — regístrate solo cuando estés listo para ordenar.",
cta_text: "Ver disponibilidad actual",
cta_url: "/wholesale/portal",
},
3: {
}),
3: Object.freeze({
subject: "Tu primer pedido está esperando — {brand} mayorista",
heading: "¿Listo para probarnos?",
body: "Si has estado pensando en hacer tu primer pedido al por mayor con {brand}, ahora es un excelente momento.\n\nNuestra selección actual de temporada incluye productos de nuestra granja y productores asociados, seleccionados por su frescura y calidad.\n\n¿Preguntas? Responde a este correo — leemos cada mensaje.",
cta_text: "Comenzar mi primer pedido",
cta_url: "/wholesale/register",
},
4: {
}),
4: Object.freeze({
subject: "Todo listo — actualizaciones mayoristas de {brand}",
heading: "Todo está listo",
body: "Ahora estás completamente configurado para recibir actualizaciones mayoristas de {brand}.\n\nTe enviaremos correos ocasionales sobre nuevos productos, disponibilidad por temporada y ofertas especiales. Sin spam — solo cosas útiles.\n\nPuedes darte de baja en cualquier momento.",
cta_text: "Explorar el catálogo",
cta_url: "/wholesale/portal",
},
},
};
}),
}),
});
// ── Get all welcome sequence entries (admin view) ────────────────────────────
export async function getWelcomeSequence(brandId: string): Promise<GetWelcomeSequenceResult> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -182,7 +185,8 @@ export async function sendWelcomeEmail(
entry: WelcomeSequenceEntry,
step: number
): Promise<{ success: boolean; error?: string }> {
const { brand_name, contact_name, locale, contact_email } = entry;
await getSession(); const { brand_name, contact_name, locale, contact_email } = entry;
const t = WELCOME_EMAILS[locale]?.[step] ?? WELCOME_EMAILS.en[step];
const ctaUrl = t.cta_url;
@@ -232,6 +236,8 @@ export async function resendWelcomeEmail(
entryId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
+5 -2
View File
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns";
import { getSession } from "@/lib/auth";
/**
* `harvest-reach/campaigns` re-exports the marketing `Campaign` shape
@@ -77,7 +78,8 @@ function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
export async function getHarvestReachCampaigns(
brandId: string
): Promise<{ success: true; campaigns: Campaign[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, error: "Not authorized" };
@@ -112,7 +114,8 @@ export async function getCampaignAnalytics(
brandId: string,
campaignId?: string
): Promise<CampaignAnalytics[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
+3 -1
View File
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type ProductOption = {
id: string;
@@ -22,7 +23,8 @@ export type ProductOption = {
export async function getProductsForSegmentPicker(
brandId: string
): Promise<ProductOption[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
+9 -4
View File
@@ -5,6 +5,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { customers, brandSettings } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns";
import { getSession } from "@/lib/auth";
export type SegmentFilterType =
| "all_customers"
@@ -104,7 +105,8 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
export async function getHarvestReachSegments(
brandId: string
): Promise<{ success: true; segments: Segment[] } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
@@ -129,7 +131,8 @@ export async function upsertHarvestReachSegment(params: {
description?: string;
rules: SegmentRuleV2;
}): Promise<{ success: true; segment: Segment } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== params.brand_id) {
@@ -178,7 +181,8 @@ export async function deleteHarvestReachSegment(
segmentId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
@@ -213,7 +217,8 @@ export async function previewSegmentWithCustomers(
brandId: string,
rules: SegmentRuleV2
): Promise<PreviewResult | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
+3 -1
View File
@@ -4,6 +4,7 @@ import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { stops } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type StopOption = {
id: string;
@@ -46,7 +47,8 @@ export async function getStopsForSegmentPicker(
brandId: string,
stopId?: string
): Promise<StopOption[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withTx, pool } from "@/lib/db";
import { orders, orderItems, customers } from "@/db/schema";
import { eq } from "drizzle-orm";
import { getSession } from "@/lib/auth";
export type ImportOrdersResult =
| { success: true; imported: number; errors: { row: number; error: string }[] }
@@ -29,7 +30,8 @@ export async function importOrdersBatch(
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
}>
): Promise<ImportOrdersResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type ImportProductsResult =
| { success: true; created: number; updated: number; errors: { product: string; error: string }[] }
@@ -27,7 +28,8 @@ export async function importProductsBatch(
image_url?: string;
}>
): Promise<ImportProductsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
+13 -6
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { DEFAULT_MODELS, type AIProvider as AIProviderType } from "@/lib/ai-provider-models";
import { getSession } from "@/lib/auth";
// ── Types ────────────────────────────────────────────────────────────────────────
@@ -33,7 +34,8 @@ const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
// ── Get AI provider settings ─────────────────────────────────────────────────────
export async function getAIProviderSettings(brandId: string): Promise<AIProviderSettings> {
try {
await getSession(); try {
const res = await pool.query<{
provider: string | null;
api_key: string | null;
@@ -66,7 +68,8 @@ export async function setAIProviderSettings(
brandId: string,
settings: Partial<AIProviderSettings>
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
@@ -100,7 +103,8 @@ export async function getAIClient(brandId: string): Promise<{
model: string;
client: unknown;
} | { provider: "openai"; model: string; client: null; error: string }> {
const settings = await getAIProviderSettings(brandId);
await getSession(); const settings = await getAIProviderSettings(brandId);
if (!settings.apiKey) {
// Fall back to env var. Check the saved provider first, then universal env keys.
@@ -193,7 +197,8 @@ export async function getAIClient(brandId: string): Promise<{
// ── Custom integrations ─────────────────────────────────────────────────────────
export async function getCustomIntegrations(brandId: string): Promise<CustomIntegration[]> {
try {
await getSession(); try {
const res = await pool.query<CustomIntegration>(
"SELECT * FROM get_custom_integrations($1)",
[brandId]
@@ -208,7 +213,8 @@ export async function upsertCustomIntegration(
brandId: string,
integration: CustomIntegration
): Promise<{ success: boolean; integrations?: CustomIntegration[]; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
@@ -228,7 +234,8 @@ export async function deleteCustomIntegration(
brandId: string,
integrationId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (adminUser.brand_id && adminUser.brand_id !== brandId) return { success: false, error: "Not authorized" };
+13 -6
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -24,7 +25,8 @@ export type SaveCredentialsResult =
// ── Resend Credentials ─────────────────────────────────────────────────────────
export async function getResendCredentials(brandId: string): Promise<ResendCredentials> {
try {
await getSession(); try {
const res = await pool.query<{
api_key: string | null;
from_email: string | null;
@@ -53,7 +55,8 @@ export async function saveResendCredentials(
from_name: string | null;
}>
): Promise<SaveCredentialsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
@@ -85,7 +88,8 @@ export async function saveResendCredentials(
// ── Twilio Credentials ─────────────────────────────────────────────────────────
export async function getTwilioCredentials(brandId: string): Promise<TwilioCredentials> {
try {
await getSession(); try {
const res = await pool.query<{
account_sid: string | null;
auth_token: string | null;
@@ -114,7 +118,8 @@ export async function saveTwilioCredentials(
phone_number: string | null;
}>
): Promise<SaveCredentialsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
@@ -149,7 +154,8 @@ export async function testResendConnection(apiKey: string): Promise<{
ok: boolean;
message: string;
}> {
if (!apiKey?.trim()) {
await getSession(); if (!apiKey?.trim()) {
return { ok: false, message: "API key is required" };
}
@@ -177,7 +183,8 @@ export async function testTwilioConnection(
accountSid: string,
authToken: string
): Promise<{ ok: boolean; message: string }> {
if (!accountSid?.trim() || !authToken?.trim()) {
await getSession(); if (!accountSid?.trim() || !authToken?.trim()) {
return { ok: false, message: "Account SID and Auth Token are required" };
}
+15 -7
View File
@@ -4,6 +4,7 @@ import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type LocationInput = {
name: string;
@@ -44,7 +45,8 @@ export async function createLocation(
brandId: string,
input: LocationInput
): Promise<{ success: true; id: string; slug: string } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized to manage locations" };
@@ -94,7 +96,8 @@ export async function createLocationsBatch(
brandId: string,
locations: LocationInput[]
): Promise<{ success: boolean; created: number; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized" };
@@ -135,7 +138,8 @@ export async function updateLocation(
brandId: string,
updates: Partial<LocationInput>
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
@@ -167,7 +171,8 @@ export async function deleteLocation(
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
@@ -198,7 +203,8 @@ export async function deleteLocation(
export async function adminListLocations(
brandId: string
): Promise<LocationWithCount[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const activeBrandId = await getActiveBrandId(adminUser, brandId);
@@ -224,7 +230,8 @@ export type PublicLocation = Pick<
export async function getPublicLocationsForBrand(
brandSlug: string
): Promise<PublicLocation[]> {
if (!brandSlug) return [];
await getSession(); if (!brandSlug) return [];
try {
const { rows } = await pool.query<PublicLocation>(
@@ -243,7 +250,8 @@ export async function attachStopToLocation(
locationId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -1,6 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
/**
* Offline mutation dispatcher.
@@ -46,7 +47,8 @@ interface ClientAction {
}
export async function listClientActions(): Promise<ClientAction[]> {
// Wire real server actions here as they land:
await getSession(); // Wire real server actions here as they land:
// { name: "markOrderReady", handler: markOrderReady },
// { name: "markOrderPickedUp", handler: markOrderPickedUp },
// { name: "updateStopStatus", handler: updateStopStatus },
@@ -63,7 +65,8 @@ export async function dispatchClientAction(
| { ok: false; conflict: string }
| { ok: false; error: string }
> {
const actions = await listClientActions();
await getSession(); const actions = await listClientActions();
const action = actions.find((a) => a.name === actionName);
if (!action) {
return { ok: false, error: "Unknown action" };
+13 -6
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type AdminOrder = {
id: string;
@@ -106,7 +107,8 @@ type AdminOrderDetail = {
};
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null;
// The legacy `get_admin_orders` RPC is a SECURITY DEFINER function that
@@ -135,25 +137,29 @@ export async function getAdminOrders(): Promise<AdminOrdersResponse> {
}
export async function getAdminStops(): Promise<AdminStop[]> {
const result = await getAdminOrders();
await getSession(); const result = await getAdminOrders();
if (!result.success) return [];
return result.stops;
}
export async function getAdminPendingOrders(): Promise<AdminOrder[]> {
const result = await getAdminOrders();
await getSession(); const result = await getAdminOrders();
if (!result.success) return [];
return result.orders.filter((o) => !o.pickup_complete);
}
export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
const result = await getAdminOrders();
await getSession(); const result = await getAdminOrders();
if (!result.success) return [];
return result.orders.filter((o) => o.pickup_complete);
}
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null;
try {
@@ -179,7 +185,8 @@ export async function toggleOrderPickupComplete(params: {
orderId: string;
pickupComplete: boolean;
}): Promise<{ success: true } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { randomUUID } from "crypto";
import { getSession } from "@/lib/auth";
export type AdminCreateOrderItem = {
product_id: string;
@@ -39,7 +40,8 @@ export async function createAdminOrder(
brandId: string | null,
input: AdminCreateOrderInput
): Promise<AdminCreateOrderResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
import { getSession } from "@/lib/auth";
export type CreateRefundResult =
| { success: true; id: string }
@@ -18,7 +19,8 @@ export async function createRefund(
processor_refund_id?: string | null;
}
): Promise<CreateRefundResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+7 -3
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
import { getSession } from "@/lib/auth";
export type UpdateOrderResult =
| { success: true }
@@ -28,7 +29,8 @@ export async function updateOrder(
payment_transaction_id?: string | null;
}
): Promise<UpdateOrderResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -99,7 +101,8 @@ export async function updateOrderItem(
itemId: string,
data: { quantity?: number; price?: number }
): Promise<UpdateOrderResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -133,7 +136,8 @@ export async function updateOrderItem(
}
export async function deleteOrderItem(itemId: string): Promise<UpdateOrderResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type PaymentProvider = "stripe" | "square" | "manual";
@@ -26,7 +27,8 @@ export type GetPaymentSettingsResult =
| { success: false; error: string };
export async function getPaymentSettings(brandId: string): Promise<GetPaymentSettingsResult> {
try {
await getSession(); try {
const { rows } = await pool.query<PaymentSettings>(
"SELECT * FROM get_payment_settings($1)",
[brandId],
@@ -52,7 +54,8 @@ export async function savePaymentSettings(params: {
squareSyncEnabled?: boolean;
squareInventoryMode?: "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
}): Promise<SavePaymentSettingsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { logAuditEvent } from "@/actions/audit";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
type MarkPickupResult =
| { success: true; pickup_completed_at: string; pickup_completed_by: string | null }
@@ -12,7 +13,8 @@ export async function markPickupComplete(
orderId: string,
brandId: string | null
): Promise<MarkPickupResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
+3 -1
View File
@@ -4,12 +4,14 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
import { getSession } from "@/lib/auth";
export async function deleteProduct(
productId: string,
brandId: string | null
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated" };
}
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { getSession } from "@/lib/auth";
export type CreateProductResult =
| { success: true; id: string }
@@ -21,7 +22,8 @@ export async function createProduct(
pickup_type?: string;
}
): Promise<CreateProductResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -4,6 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq } from "drizzle-orm";
import { getSession } from "@/lib/auth";
export type UpdateProductResult =
| { success: true }
@@ -23,7 +24,8 @@ export async function updateProduct(
pickup_type?: string;
}
): Promise<UpdateProductResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -13,7 +13,8 @@ export async function uploadProductImage(
productId: string,
file: File
): Promise<UploadProductImageResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const validTypes = ["image/png", "image/jpeg", "image/webp"];
@@ -54,7 +55,8 @@ export async function uploadProductImage(
export async function deleteProductImage(
productId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// In the new schema, "clearing" an image means removing the row(s)
@@ -72,3 +74,4 @@ export async function deleteProductImage(
// Imported lazily to avoid a circular dep with the table ref above.
import { eq } from "drizzle-orm";
import { getSession } from "@/lib/auth";
+15 -7
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type DateRange = { start: string; end: string };
@@ -95,7 +96,8 @@ function brandParams(brandId: string | null): unknown[] {
// ── Report actions ──────────────────────────────────────────────────────────
export async function getReportsSummary(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -132,7 +134,8 @@ export async function getReportsSummary(range: DateRange, brandId: string | null
}
export async function getOrdersByStopReport(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -144,7 +147,8 @@ export async function getOrdersByStopReport(range: DateRange, brandId: string |
}
export async function getSalesByProductReport(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -173,7 +177,8 @@ export async function getSalesByProductReport(range: DateRange, brandId: string
}
export async function getFulfillmentReport(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -209,7 +214,8 @@ export async function getFulfillmentReport(range: DateRange, brandId: string | n
}
export async function getPickupStatusByStop(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -221,7 +227,8 @@ export async function getPickupStatusByStop(range: DateRange, brandId: string |
}
export async function getContactGrowthReport(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
@@ -245,7 +252,8 @@ export async function getContactGrowthReport(range: DateRange, brandId: string |
}
export async function getCampaignActivityReport(range: DateRange, brandId: string | null = null) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) throw new Error("Not authenticated");
const effectiveBrandId = adminUser.role === "brand_admin" ? adminUser.brand_id : brandId;
+29 -14
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
import { getSession } from "@/lib/auth";
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
// tables from the route-trace feature (originally defined in the now-
@@ -200,13 +201,15 @@ async function assertCanAccessBrand(brandId: string): Promise<
}
export async function getRouteTraceLots(brandId: string, _status?: string): Promise<LotsResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getRouteTraceLotDetail(_lotId: string): Promise<LotDetailResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
return { success: false, lot: null, error: "Route-trace feature not configured" };
}
@@ -233,7 +236,8 @@ export async function createHarvestLot(
brandId: string,
_data: CreateLotData
): Promise<CreatedLotResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lot: null, error: auth.error };
return { success: false, lot: null, error: "Route-trace feature not configured" };
}
@@ -245,13 +249,15 @@ export async function updateHarvestLotStatus(
_notes?: string,
_binId?: string
): Promise<UpdatedLotResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, lot: null, error: "Unauthorized" };
return { success: false, lot: null, error: "Route-trace feature not configured" };
}
export async function getRouteTraceStats(brandId: string): Promise<StatsResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, stats: null, error: auth.error };
return {
success: true,
@@ -268,31 +274,36 @@ export async function getRouteTraceStats(brandId: string): Promise<StatsResult>
}
export async function searchHarvestLots(brandId: string, _query: string): Promise<HarvestLotsResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getTraceChain(_lotId: string): Promise<ChainResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, chain: null, error: "Unauthorized" };
return { success: false, chain: null, error: "Route-trace feature not configured" };
}
export async function getHarvestLotsReadyToHaul(brandId: string): Promise<LotsResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, lots: null, error: auth.error };
return { success: true, lots: [], error: null };
}
export async function getFieldYieldSummary(brandId: string): Promise<YieldResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, summary: null, error: auth.error };
return { success: true, summary: [], error: null };
}
export async function getLotOrders(_lotId: string): Promise<OrdersResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, orders: null, error: "Unauthorized" };
return { success: true, orders: [], error: null };
}
@@ -307,7 +318,8 @@ export interface InventoryByCrop {
}
export async function getInventoryByCrop(brandId: string): Promise<InventoryResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, inventory: null, error: auth.error };
return { success: true, inventory: [], error: null };
}
@@ -318,7 +330,8 @@ export async function markLotUsedInOrder(
_quantityToAdd?: number,
_notes?: string
): Promise<{ success: true } | { success: false; error: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Unauthorized" };
return { success: false, error: "Route-trace feature not configured" };
}
@@ -327,7 +340,8 @@ export async function getRecentLotEvents(
brandId: string,
_limit = 10
): Promise<EventsResult> {
const auth = await assertCanAccessBrand(brandId);
await getSession(); const auth = await assertCanAccessBrand(brandId);
if (!auth.ok) return { success: false, events: null, error: auth.error };
return { success: true, events: [], error: null };
}
@@ -343,5 +357,6 @@ export async function getRecentLotEvents(
* this with a real Drizzle query.
*/
export async function getLotIdByNumber(_lotNumber: string): Promise<string | null> {
return null;
await getSession(); return null;
}
+3 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
import {
setActiveBrandCookie,
clearActiveBrandCookie,
@@ -20,7 +21,8 @@ import {
export async function setActiveBrand(
brandId: string | null
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
+3 -1
View File
@@ -5,6 +5,7 @@ import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getSession } from "@/lib/auth";
import {
invalidateBrandFeatureCache,
type BrandFeatureKey,
@@ -26,7 +27,8 @@ export async function toggleBrandFeature(
featureKey: BrandFeatureKey,
enabled: boolean
): Promise<ToggleFeatureResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type UpdateShippingStatusResult =
| { success: true }
@@ -20,7 +21,8 @@ export async function updateShippingStatus(
_status: string,
_trackingNumber?: string
): Promise<UpdateShippingStatusResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
return { success: false, error: "Shipping not configured" };
@@ -54,7 +56,8 @@ export type ShippingOrder = {
};
export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Read shipping-eligible orders from the new schema as a best-effort
+8 -14
View File
@@ -16,6 +16,8 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import type { FedExServiceType } from "./fedex-rates";
import { getSession } from "@/lib/auth";
import { readFedExToken, writeFedExToken, clearFedExToken } from "@/lib/shipping/fedex-token-cache";
export type CreateShipmentResult =
| { success: true; shipmentId: string; trackingNumber: string; labelUrl: string }
@@ -24,13 +26,6 @@ export type CreateShipmentResult =
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
interface FedExAuthToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(settings: {
fedexApiKey: string;
fedexApiSecret: string;
@@ -38,8 +33,9 @@ async function getFedExToken(settings: {
}): Promise<{ accessToken: string } | { error: string }> {
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return { accessToken: cachedToken.accessToken };
const cached = readFedExToken();
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
return { accessToken: cached.accessToken };
}
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
@@ -59,10 +55,7 @@ async function getFedExToken(settings: {
}
const data = await res.json();
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
writeFedExToken(data.access_token, data.expires_in);
return { accessToken: data.access_token };
}
@@ -123,7 +116,8 @@ export async function createFedExShipment(
rateCharged: number, // cents
estimatedDeliveryDate: string
): Promise<CreateShipmentResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) {
return { success: false, error: "Not authorized to create shipments" };
+8 -14
View File
@@ -22,6 +22,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { readFedExToken, writeFedExToken } from "@/lib/shipping/fedex-token-cache";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -55,13 +57,6 @@ export type RateResult =
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
interface FedExAuthToken {
accessToken: string;
expiresAt: number; // epoch ms
}
let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(settings: {
fedexApiKey: string;
fedexApiSecret: string;
@@ -70,8 +65,9 @@ async function getFedExToken(settings: {
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
// Reuse cached token if still valid (5 min buffer)
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return { accessToken: cachedToken.accessToken };
const cached = readFedExToken();
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
return { accessToken: cached.accessToken };
}
const credentials = btoa(`${settings.fedexApiKey}:${settings.fedexApiSecret}`);
@@ -91,10 +87,7 @@ async function getFedExToken(settings: {
}
const data = await res.json();
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
writeFedExToken(data.access_token, data.expires_in);
return { accessToken: data.access_token };
}
@@ -152,7 +145,8 @@ async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSe
export async function getFedExRates(
orderId: string
): Promise<RateResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) {
return { success: false, error: "Not authorized to view shipping rates" };
+12 -13
View File
@@ -15,6 +15,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { readFedExToken, writeFedExToken } from "@/lib/shipping/fedex-token-cache";
// ── Types ──────────────────────────────────────────────────────────────────────
@@ -49,13 +51,6 @@ export type TestConnectionResult =
const FEDEX_BASE_URL = "https://apis.fedex.com";
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
interface FedExAuthToken {
accessToken: string;
expiresAt: number;
}
let cachedToken: FedExAuthToken | null = null;
async function getFedExToken(
apiKey: string,
apiSecret: string,
@@ -63,8 +58,9 @@ async function getFedExToken(
): Promise<{ accessToken: string } | { error: string }> {
const base = useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return { accessToken: cachedToken.accessToken };
const cached = readFedExToken();
if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) {
return { accessToken: cached.accessToken };
}
const credentials = btoa(`${apiKey}:${apiSecret}`);
@@ -83,10 +79,7 @@ async function getFedExToken(
}
const data = await res.json();
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
writeFedExToken(data.access_token, data.expires_in);
return { accessToken: data.access_token };
}
@@ -94,6 +87,8 @@ async function getFedExToken(
// ── Get Settings ─────────────────────────────────────────────────────────────
export async function getShippingSettings(brandId: string): Promise<GetShippingSettingsResult> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
try { assertBrandAccess(adminUser, brandId); } catch { return { success: false, error: "Brand access denied" }; }
@@ -131,6 +126,8 @@ export async function saveShippingSettings(params: {
refrigeratedHandlingNotes?: string;
fragileHandlingNotes?: string;
}): Promise<SaveShippingSettingsResult> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -229,6 +226,8 @@ export async function testFedExConnection(
fedexApiSecret: string,
fedexUseProduction: boolean
): Promise<TestConnectionResult> {
await getSession();
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -5,6 +5,7 @@ import { getPaymentSettings } from "@/actions/payments";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
import { getSession } from "@/lib/auth";
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
@@ -91,7 +92,8 @@ export async function syncInventoryToSquare(
brandId: string,
items: Array<{ productId: string; quantity: number }>
): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
const settingsResult = await getPaymentSettings(brandId);
@@ -152,7 +154,8 @@ export async function syncInventoryToSquare(
* Fetches Square inventory counts and updates RC product inventory.
*/
export async function syncInventoryFromSquare(brandId: string): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
@@ -67,7 +68,8 @@ export type SyncResult = {
};
export async function syncOrdersFromSquare(brandId: string): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
+5 -2
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type SquareCatalogItem = {
id: string;
@@ -101,7 +102,8 @@ export type SyncResult = {
};
export async function syncProductsToSquare(brandId: string): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
@@ -203,7 +205,8 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
}
export async function syncProductsFromSquare(brandId: string): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
+7 -3
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type SyncLogEntry = {
id: string;
@@ -27,7 +28,8 @@ export async function syncSquareNow(
brandId: string,
type: "products" | "orders" | "all"
): Promise<SyncResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (!adminUser.can_manage_orders) return { success: false, synced: 0, errors: ["Not authorized"] };
try {
@@ -61,7 +63,8 @@ export async function getSyncLog(brandId: string): Promise<{
success: boolean;
logs: SyncLogEntry[];
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, logs: [] };
if (!adminUser.can_manage_orders) return { success: false, logs: [] };
try {
@@ -85,7 +88,8 @@ export async function getSyncLog(brandId: string): Promise<{
* throws, so callers can render "0" while the auth check settles.
*/
export async function getSquareQueueCount(brandId: string): Promise<number> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return 0;
try {
assertBrandAccess(adminUser, brandId);
+11 -5
View File
@@ -4,6 +4,7 @@ import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StopImportRow = {
city: string;
@@ -20,7 +21,8 @@ export async function createStopsBatch(
brandId: string,
stops: StopImportRow[]
): Promise<{ success: boolean; created: number; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, created: 0, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, created: 0, error: "Not authorized to manage stops" };
@@ -77,7 +79,8 @@ export async function publishStop(
stopId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
@@ -113,7 +116,8 @@ export async function deleteStop(
stopId: string,
brandId: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
@@ -151,7 +155,8 @@ export type StopForSitemap = {
};
export async function getActiveStopsForSitemap(): Promise<StopForSitemap[]> {
// Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
await getSession(); // Wrapped in try/catch so a build-time outage (ECONNREFUSED) doesn't
// crash the prerender — the sitemap just renders without stop URLs.
try {
const { rows } = await pool.query<StopForSitemap>(
@@ -186,7 +191,8 @@ export type PublicStop = {
export async function getPublicStopsForBrand(
brandSlug: string
): Promise<PublicStop[]> {
if (!brandSlug) return [];
await getSession(); if (!brandSlug) return [];
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
// crash the prerender — the page just renders with no stops and
+3 -1
View File
@@ -3,6 +3,7 @@
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type CreateStopResult =
| { success: true; id: string }
@@ -22,7 +23,8 @@ export async function createStop(
active?: boolean;
}
): Promise<CreateStopResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -13,6 +13,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StopCustomer = {
id: string;
@@ -29,7 +30,8 @@ export type GetStopPendingCustomersResult =
export async function getStopPendingCustomers(
stopId: string
): Promise<GetStopPendingCustomersResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StopDetail = {
id: string;
@@ -44,7 +45,8 @@ export type StopDetailsResult =
* component loaded, so the modal can be a drop-in replacement.
*/
export async function getStopDetails(stopId: string): Promise<StopDetailsResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) {
return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -15,6 +15,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type AssignProductResult =
| { success: true; id: string }
@@ -28,7 +29,8 @@ export async function assignProductToStop(params: {
stopId: string;
productId: string;
}): Promise<AssignProductResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
@@ -56,7 +58,8 @@ export async function unassignProductFromStop(params: {
stopId: string;
productId: string;
}): Promise<UnassignProductResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products && !adminUser.can_manage_orders) {
return { success: false, error: "Not authorized" };
+3 -1
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
import { getSession } from "@/lib/auth";
export type UpdateStopResult =
| { success: true }
@@ -23,7 +24,8 @@ export async function updateStop(
cutoff_time?: string | null;
}
): Promise<UpdateStopResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_stops) return { success: false, error: "Not authorized" };
+17 -8
View File
@@ -10,6 +10,7 @@
*/
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type StorefrontBrand = {
id: string;
@@ -60,7 +61,8 @@ export type StorefrontData = {
export async function getStorefrontBrandBySlug(
slug: string,
): Promise<StorefrontBrand | null> {
const { rows } = await pool.query<StorefrontBrand>(
await getSession(); const { rows } = await pool.query<StorefrontBrand>(
`SELECT id, name, slug
FROM brands
WHERE slug = $1
@@ -75,7 +77,8 @@ export async function getStorefrontBrandBySlug(
* and any stop whose date has already passed.
*/
export async function getStorefrontStops(brandId: string): Promise<StorefrontStop[]> {
const { rows } = await pool.query<StorefrontStop>(
await getSession(); const { rows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
@@ -101,7 +104,8 @@ export async function getStorefrontStops(brandId: string): Promise<StorefrontSto
export async function getStorefrontProducts(
brandId: string,
): Promise<StorefrontProduct[]> {
const { rows } = await pool.query<StorefrontProduct>(
await getSession(); const { rows } = await pool.query<StorefrontProduct>(
`SELECT id, brand_id, name, description,
price::float8 / 100 AS price,
type, image_url, is_taxable, pickup_type, active
@@ -124,7 +128,8 @@ export async function getStorefrontProducts(
* match any brand — pages fall back to default copy in that case.
*/
export async function getStorefrontData(slug: string): Promise<StorefrontData> {
const brand = await getStorefrontBrandBySlug(slug);
await getSession(); const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return { brand: null, stops: [], products: [] };
const [stops, products] = await Promise.all([
getStorefrontStops(brand.id),
@@ -149,7 +154,8 @@ export type StorefrontWholesaleSettings = {
export async function getStorefrontWholesaleSettings(
slug: string,
): Promise<StorefrontWholesaleSettings | null> {
const brand = await getStorefrontBrandBySlug(slug);
await getSession(); const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>(
`SELECT invoice_business_name
@@ -175,7 +181,8 @@ export type WholesalePortalConfig = {
export async function getWholesalePortalConfig(
brandId: string,
): Promise<WholesalePortalConfig | null> {
const { rows } = await pool.query<WholesalePortalConfig>(
await getSession(); const { rows } = await pool.query<WholesalePortalConfig>(
`SELECT wholesale_enabled, online_payment_enabled
FROM wholesale_settings
WHERE brand_id = $1
@@ -195,7 +202,8 @@ export type BrandName = { name: string };
export async function getStorefrontBrandName(
brandId: string,
): Promise<string | null> {
const { rows } = await pool.query<BrandName>(
await getSession(); const { rows } = await pool.query<BrandName>(
`SELECT name FROM brands WHERE id = $1 LIMIT 1`,
[brandId],
);
@@ -217,7 +225,8 @@ export async function getStorefrontStopById(id: string): Promise<{
stop: StorefrontStop;
products: StorefrontProduct[];
} | null> {
const { rows: stopRows } = await pool.query<StorefrontStop>(
await getSession(); const { rows: stopRows } = await pool.query<StorefrontStop>(
`SELECT id, brand_id, name, city, state, zip, address, location,
date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public
+18 -11
View File
@@ -3,9 +3,10 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import Stripe from "stripe";
import { getSession } from "@/lib/auth";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
type StripeApiVersion = "2026-06-24.dahlia";
/**
* Get Stripe Connect status for a brand.
@@ -19,7 +20,8 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
details_submitted?: boolean;
error?: string;
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { is_connected: false, error: "Not authenticated" };
// Get brand's payment settings via SECURITY DEFINER RPC
@@ -41,7 +43,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
return { is_connected: true, account_id: stripeUserId };
}
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const account = await stripe.accounts.retrieve(stripeUserId);
return {
@@ -73,7 +75,8 @@ export async function createStripeConnectLink(brandId: string): Promise<{
account_id?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -85,7 +88,7 @@ export async function createStripeConnectLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// Create Express account
@@ -128,7 +131,8 @@ export async function refreshStripeConnectLink(brandId: string): Promise<{
url?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -147,7 +151,7 @@ export async function refreshStripeConnectLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const accountLink = await stripe.accountLinks.create({
@@ -174,7 +178,8 @@ export async function saveStripeConnectAccount(brandId: string, accountId: strin
success: boolean;
error?: string;
}> {
try {
await getSession(); try {
// Save to payment_settings via SECURITY DEFINER RPC
await pool.query(
"SELECT set_stripe_connect_account($1, $2)",
@@ -194,7 +199,8 @@ export async function disconnectStripeConnect(brandId: string): Promise<{
success: boolean;
error?: string;
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -220,7 +226,8 @@ export async function createStripeDashboardLink(brandId: string): Promise<{
url?: string;
error?: string;
}> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
return { success: false, error: "Not authorized" };
@@ -237,7 +244,7 @@ export async function createStripeDashboardLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-06-24.dahlia" as StripeApiVersion });
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
return {
+7 -3
View File
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export type TaxCalculationResult = {
taxAmount: number;
@@ -31,7 +32,8 @@ export async function calculateOrderTax(params: {
fulfillment: "pickup" | "ship";
shippingAddress?: { state: string; postal_code: string; city?: string };
}): Promise<TaxCalculationResult> {
// 1. If not collecting tax, return zero
await getSession(); // 1. If not collecting tax, return zero
const taxSettings = await getBrandTaxSettings(params.brandId);
if (!taxSettings?.collect_sales_tax) {
return { taxAmount: 0, taxRate: 0, taxLocation: "" };
@@ -198,7 +200,8 @@ export async function getTaxSummaryAction(params: {
startDate: string;
endDate: string;
}): Promise<GetTaxSummaryResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
await getSession(); if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
total_tax_collected: number | string;
@@ -233,7 +236,8 @@ export async function getTaxableOrdersAction(params: {
startDate: string;
endDate: string;
}): Promise<GetTaxableOrdersResult> {
if (!params.brandId) return { success: false, error: "Brand required" };
await getSession(); if (!params.brandId) return { success: false, error: "Brand required" };
try {
const { rows } = await pool.query<{
order_id: string;
+19 -4
View File
@@ -1,6 +1,7 @@
"use server";
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
// TODO(migration): the time-tracking feature was built on Supabase RPCs
// (verify_time_tracking_pin, clock_in_worker, clock_out_worker,
@@ -56,7 +57,8 @@ export async function verifyTimeTrackingPin(
_brandId: string,
_pin: string
): Promise<{ success: boolean; session?: TimeTrackingSession; error?: string }> {
// RPC no longer exists; surface a friendly error so the field UI can
await getSession(); // RPC no longer exists; surface a friendly error so the field UI can
// disable the PIN pad.
return { success: false, error: "Time tracking is not configured" };
}
@@ -68,6 +70,8 @@ export async function clockInWorker(
_taskId?: string,
_taskName = "General Labor"
): Promise<{ success: boolean; log_id?: string; clock_in?: string; error?: string }> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
@@ -79,6 +83,8 @@ export async function clockOutWorker(
_lunchMinutes = 0,
_notes?: string
): Promise<{ success: boolean; log_id?: string; clock_out?: string; total_minutes?: number; error?: string }> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: false, error: "Time tracking is not configured" };
@@ -97,6 +103,8 @@ export async function getOpenClockIn(
elapsed_minutes?: number;
error?: string;
}> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { success: false, error: "Not logged in" };
return { success: true, open: false };
@@ -105,6 +113,8 @@ export async function getOpenClockIn(
// ── Logout ─────────────────────────────────────────────────────────────────────
export async function logoutTimeTracking(): Promise<void> {
await getSession();
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
@@ -112,6 +122,8 @@ export async function logoutTimeTracking(): Promise<void> {
// ── Get Current Session ────────────────────────────────────────────────────────
export async function getTimeTrackingSession(): Promise<TimeTrackingSession | null> {
await getSession();
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE);
if (!cookie) return null;
@@ -133,7 +145,8 @@ export async function getTimeTrackingTasksField(
_brandId: string,
_activeOnly = true
): Promise<TimeTaskField[]> {
return [];
await getSession(); return [];
}
// ── Pay Period Hours ───────────────────────────────────────────────────────────
@@ -154,7 +167,7 @@ export type PayPeriodHours = {
weekly_threshold: number;
};
const EMPTY_PAY_PERIOD: PayPeriodHours = {
const EMPTY_PAY_PERIOD: Readonly<PayPeriodHours> = Object.freeze({
success: false,
total_minutes: 0,
total_hours: 0,
@@ -168,11 +181,13 @@ const EMPTY_PAY_PERIOD: PayPeriodHours = {
period_end: "",
daily_threshold: 12,
weekly_threshold: 56,
};
});
export async function getWorkerPayPeriodHours(
_brandId: string
): Promise<PayPeriodHours> {
await getSession();
const session = await getTimeTrackingSession();
if (!session) return { ...EMPTY_PAY_PERIOD };
return { ...EMPTY_PAY_PERIOD };
+33 -16
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
// create_time_worker, reset_time_worker_pin, update_time_worker,
@@ -64,7 +65,8 @@ export type TimeSummary = {
// ── Workers ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
// Time tracking tables not in SaaS rebuild — return empty list.
await getSession(); // Time tracking tables not in SaaS rebuild — return empty list.
return [];
}
@@ -74,13 +76,15 @@ export async function createTimeWorker(
_role = "worker",
_lang = "en"
): Promise<{ success: boolean; worker?: TimeWorker; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
export async function resetTimeWorkerPin(_workerId: string): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -92,13 +96,15 @@ export async function updateTimeWorker(
_lang: string,
_active: boolean
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
export async function deleteTimeWorker(_workerId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -106,7 +112,8 @@ export async function deleteTimeWorker(_workerId: string): Promise<{ success: bo
// ── Tasks ────────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
return [];
await getSession(); return [];
}
export async function createTimeTask(
@@ -116,7 +123,8 @@ export async function createTimeTask(
_unit = "hours",
_sortOrder = 0
): Promise<{ success: boolean; id?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -129,13 +137,15 @@ export async function updateTimeTask(
_active: boolean,
_sortOrder: number
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
export async function deleteTimeTask(_taskId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -153,7 +163,8 @@ export async function getWorkerTimeLogs(
offset?: number;
} = {}
): Promise<TimeLog[]> {
return [];
await getSession(); return [];
}
export async function updateWorkerTimeLog(
@@ -164,13 +175,15 @@ export async function updateWorkerTimeLog(
_lunchMinutes: number,
_notes: string | null
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -180,7 +193,8 @@ export async function getTimeTrackingSummary(
_start: string,
_end: string
): Promise<TimeSummary> {
return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
await getSession(); return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
}
// ── Time Tracking Settings ─────────────────────────────────────────────────────
@@ -205,7 +219,8 @@ export type TimeTrackingSettings = {
};
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
// Real RPC not in SaaS rebuild.
await getSession(); // Real RPC not in SaaS rebuild.
return null;
}
@@ -228,7 +243,8 @@ export async function updateTimeTrackingSettings(
brand_name?: string;
}
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
return { success: false, error: "Time tracking is not configured" };
}
@@ -254,5 +270,6 @@ export async function getTimeTrackingNotificationLog(
_brandId: string,
_limit = 100
): Promise<NotificationLogEntry[]> {
return [];
await getSession(); return [];
}
+3 -1
View File
@@ -1,6 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
// TODO(migration): the `check_and_notify_overtime` SECURITY DEFINER
// RPC and the time-tracking notification tables are not part of the
@@ -23,7 +24,8 @@ export async function checkAndNotifyOvertime(
_dailyHours: number,
_weeklyHours: number
): Promise<OvertimeCheckResult> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) {
return { sent: false, message: "Not authenticated" };
}
+37 -18
View File
@@ -31,6 +31,7 @@ import {
} from "@/db/schema/water-log";
import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin";
import { logAuditEvent, logAlert } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
// ── Types used by both server and client ────────────────────────────────────
@@ -128,7 +129,8 @@ export async function requireWaterAdminSession(): Promise<
| { ok: true; adminUserId: string; brandId: string }
| { ok: false; error: string }
> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return { ok: false, error: "Not signed in" };
@@ -226,7 +228,8 @@ export async function createWaterHeadgate(
unit: string = "CFS",
options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {},
): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
@@ -279,7 +282,8 @@ export async function updateWaterHeadgate(
notes?: string | null,
status?: AdminHeadgate["status"],
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
// Headgates aren't directly brand-scoped in the URL — look up brand first.
@@ -332,7 +336,8 @@ export async function updateWaterHeadgate(
export async function deleteWaterHeadgate(
headgateId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
@@ -357,7 +362,8 @@ export async function deleteWaterHeadgate(
export async function regenerateHeadgateToken(
headgateId: string,
): Promise<{ success: boolean; token?: string; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOf(headgateId);
if (!brand) return { success: false, error: "Headgate not found" };
@@ -384,7 +390,8 @@ export async function regenerateHeadgateToken(
export async function getWaterHeadgatesAdmin(
brandId: string,
): Promise<AdminHeadgate[]> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
@@ -399,7 +406,8 @@ export async function getWaterHeadgatesAdmin(
// ── Irrigator (user) CRUD ──────────────────────────────────────────────────
export async function getWaterIrrigators(brandId: string): Promise<AdminIrrigator[]> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db
@@ -425,7 +433,8 @@ export async function createWaterUser(
language: string = "en",
phone?: string | null,
): Promise<CreateUserResult> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const trimmed = name?.trim();
if (!trimmed) return { success: false, error: "Name is required" };
@@ -484,7 +493,8 @@ export async function createWaterIrrigator(
name: string,
language: string = "en",
): Promise<CreateUserResult> {
return createWaterUser(brandId, name, "irrigator", language);
await getSession(); return createWaterUser(brandId, name, "irrigator", language);
}
export async function updateWaterIrrigator(
@@ -496,7 +506,8 @@ export async function updateWaterIrrigator(
phone?: string | null,
notes?: string | null,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId);
if (!brand) return { success: false, error: "User not found" };
@@ -535,7 +546,8 @@ export async function updateWaterIrrigator(
export async function deleteWaterUser(
userId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
@@ -560,7 +572,8 @@ export async function deleteWaterUser(
export async function resetWaterIrrigatorPin(
userId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfIrrigator(userId);
if (!brand) return { success: false, error: "User not found" };
@@ -595,7 +608,8 @@ export async function getWaterEntries(
brandId: string,
limit: number = 50,
): Promise<AdminEntry[]> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
const safeLimit = Math.min(Math.max(limit, 1), 1000);
return withBrand(brandId, async (db) => {
@@ -649,7 +663,8 @@ export async function getWaterEntries(
}
export async function getWaterEntryById(entryId: string): Promise<AdminEntry | null> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return null;
@@ -711,7 +726,8 @@ export async function updateWaterEntry(
unit?: string,
method?: AdminEntry["method"],
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
if (!Number.isFinite(measurement) || measurement < 0) {
return { success: false, error: "Measurement must be a non-negative number" };
@@ -746,7 +762,8 @@ export async function updateWaterEntry(
export async function deleteWaterEntry(
entryId: string,
): Promise<{ success: boolean; error?: string }> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return { success: false, error: auth.error };
const brand = await withPlatformAdminBrandOfEntry(entryId);
if (!brand) return { success: false, error: "Entry not found" };
@@ -773,7 +790,8 @@ export async function deleteWaterEntry(
export async function getWaterDisplaySummary(
brandId: string,
): Promise<WaterDisplaySummary | null> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return null;
return withBrand(brandId, async (db) => {
// Headgates with their latest entry
@@ -904,7 +922,8 @@ export async function getWaterAlertLog(
brandId: string,
limit: number = 50,
): Promise<AlertLogEntry[]> {
const auth = await requireWaterAdminPermission();
await getSession(); const auth = await requireWaterAdminPermission();
if (!auth.ok) return [];
return withBrand(brandId, async (db) => {
const rows = await db.execute<{
+21 -10
View File
@@ -30,6 +30,7 @@ import {
} from "@/db/schema/water-log";
import { verifyPin, validatePin } from "@/lib/water-log-pin";
import { logAlert } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
// Field sessions last 8h — a working day in the field. Long enough
// that a single sign-in covers a morning shift + afternoon shift.
@@ -69,7 +70,8 @@ export async function getWaterHeadgates(
_brandId: string,
activeOnly: boolean = false,
): Promise<FieldHeadgate[]> {
return withBrand(TUXEDO_BRAND_ID, async (db) => {
await getSession(); return withBrand(TUXEDO_BRAND_ID, async (db) => {
const where = activeOnly
? and(
eq(waterHeadgates.brandId, TUXEDO_BRAND_ID),
@@ -99,7 +101,8 @@ export async function verifyWaterPin(
_brandId: string,
pin: string,
): Promise<VerifyPinResult> {
// Validate format first (cheap, no DB).
await getSession(); // Validate format first (cheap, no DB).
const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
@@ -177,7 +180,8 @@ export async function submitWaterEntry(
longitude?: number,
_headgateLocked?: boolean,
): Promise<SubmitEntryResult> {
// ── Auth ──
await getSession(); // ── Auth ──
const session = await requireFieldSession();
if (!session.ok) return { success: false, error: session.error };
@@ -279,7 +283,8 @@ type FieldSession =
| { ok: false; error: string };
export async function requireFieldSession(): Promise<FieldSession> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (!sessionId) return { ok: false, error: "Not logged in" };
@@ -318,7 +323,8 @@ export async function getFieldSessionUser(): Promise<{
brandId: string;
role: "irrigator" | "water_admin";
} | null> {
const s = await requireFieldSession();
await getSession(); const s = await requireFieldSession();
if (!s.ok) return null;
return withPlatformAdmin(async (db) => {
const rows = await db
@@ -340,7 +346,8 @@ export async function getFieldSessionUser(): Promise<{
// ── Cookie/language helpers ───────────────────────────────────────────────
export async function logoutWater(): Promise<void> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value;
if (sessionId) {
// Best-effort DB cleanup
@@ -356,7 +363,8 @@ export async function logoutWater(): Promise<void> {
}
export async function logoutWaterAdmin(): Promise<void> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (sessionId) {
try {
@@ -373,7 +381,8 @@ export async function logoutWaterAdmin(): Promise<void> {
}
export async function getWaterSession(): Promise<string | null> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
return cookieStore.get("wl_session")?.value ?? null;
}
@@ -392,7 +401,8 @@ export async function getWaterAdminSession(): Promise<{
adminUserId: string;
role: "water_admin";
} | null> {
const cookieStore = await cookies();
await getSession(); const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_admin_session")?.value;
if (!sessionId) return null;
return withPlatformAdmin(async (db) => {
@@ -419,7 +429,8 @@ export async function getWaterAdminSession(): Promise<{
}
export async function setWaterLang(lang: string): Promise<void> {
if (!["en", "es"].includes(lang)) return;
await getSession(); if (!["en", "es"].includes(lang)) return;
const cookieStore = await cookies();
cookieStore.set("wl_lang", lang, {
httpOnly: false,
+9 -4
View File
@@ -24,6 +24,7 @@ import {
import { getAdminUser } from "@/lib/admin-permissions";
import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin";
import { logAuditEvent } from "@/lib/water-log-audit";
import { getSession } from "@/lib/auth";
export type AdminSettings = {
enabled: boolean;
@@ -54,7 +55,8 @@ function mapSettings(s: WaterAdminSettings): AdminSettings {
export async function getWaterAdminSettings(
brandId: string,
): Promise<AdminSettings | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
if (
!adminUser.can_manage_water_log &&
@@ -85,7 +87,8 @@ export async function saveWaterAdminSettings(
brandId: string,
settings: Partial<AdminSettings>,
): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
@@ -148,7 +151,8 @@ export async function saveWaterAdminSettings(
export async function regenerateAdminPin(
brandId: string,
): Promise<{ success: boolean; pin?: string; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (
!adminUser.can_manage_water_log &&
@@ -189,7 +193,8 @@ export async function verifyWaterAdminPin(
brandId: string,
pin: string,
): Promise<{ success: boolean; session_id?: string; error?: string }> {
const formatError = validatePin(pin);
await getSession(); const formatError = validatePin(pin);
if (formatError) return { success: false, error: formatError };
return withBrand(brandId, async (db) => {
+46 -21
View File
@@ -1,42 +1,67 @@
"use server";
/**
* Wholesale customer authentication.
*
* TODO(migration): The original implementation used Supabase's
* `auth.signInWithPassword` for email/password login of wholesale
* customers. Now that Supabase is being removed, the wholesale
* customer auth needs to be rebuilt on top of Auth.js v5 (or a
* custom bcrypt + cookie session) before this module can be re-enabled.
*
* Until that lands, the actions below are stubbed: they preserve the
* original signatures so the login form renders without runtime
* errors, but always return a friendly "not available" error.
*
* Re-enable by:
* 1. Adding a `wholesale_customers` table (or extending
* `db/schema/customers.ts` with `password_hash` + `auth_user_id`).
* 2. Wiring up the chosen auth provider in `src/lib/auth.ts`.
* 3. Setting the `wholesale_session` cookie with the resolved
* customer id.
*/
import { cookies } from "next/headers";
import { getSession } from "@/lib/auth";
export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string }
| { success: false; error: string };
export type EmployeeSession = {
userId: string;
brandId: string;
name: string;
email?: string;
brandName: string;
};
const NOT_AVAILABLE_ERROR =
"Wholesale customer login is temporarily unavailable while we rebuild authentication. Please contact your account manager.";
export async function wholesaleLoginAction(_formData: FormData): Promise<WholesaleLoginResult> {
await getSession();
return { success: false, error: NOT_AVAILABLE_ERROR };
}
export async function wholesaleLogoutAction(): Promise<{ success: true } | { success: false; error: string }> {
await getSession();
const cookieStore = await cookies();
// Best-effort cookie cleanup so a returning customer doesn't see stale state
cookieStore.delete("wholesale_session");
return { success: true };
}
export async function wholesaleEmployeeLogoutAction(): Promise<{ success: true } | { success: false; error: string }> {
await getSession();
const cookieStore = await cookies();
// Best-effort cookie cleanup so a returning employee doesn't see stale state
cookieStore.delete("employee_session");
return { success: true };
}
export async function getEmployeeSessionAction(): Promise<EmployeeSession | null> {
await getSession();
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("employee_session")?.value;
if (!sessionCookie) return null;
try {
const parsed = JSON.parse(sessionCookie) as {
user_id?: string;
brand_id?: string;
name?: string;
email?: string;
brand_name?: string;
};
if (!parsed.user_id || !parsed.brand_id) return null;
return {
userId: parsed.user_id,
brandId: parsed.brand_id,
name: parsed.name || parsed.email || "Employee",
email: parsed.email,
brandName: parsed.brand_name || "Pickup Portal",
};
} catch {
return null;
}
}
+25 -12
View File
@@ -3,6 +3,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { assertBrandAccess } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { getSession } from "@/lib/auth";
export async function registerWholesaleCustomer(params: {
brandId: string;
@@ -11,7 +12,8 @@ export async function registerWholesaleCustomer(params: {
email: string;
phone?: string;
}): Promise<{ success: boolean; error?: string; requiresApproval?: boolean }> {
try {
await getSession(); try {
const { rows } = await pool.query<{
success: boolean;
error?: string;
@@ -41,7 +43,8 @@ export async function registerWholesaleCustomer(params: {
}
export async function getPendingWholesaleRegistrations(brandId: string) {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
@@ -61,7 +64,8 @@ export async function approveWholesaleRegistration(
brandId: string,
action: "approve" | "reject"
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
@@ -99,7 +103,8 @@ export async function getWholesaleCustomerByUser(
role: string;
brand_id: string;
} | null> {
try {
await getSession(); try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_by_user($1, $2)",
[brandId, userId]
@@ -134,7 +139,8 @@ export async function getWholesaleCustomer(
role: string;
brand_id: string;
} | null> {
try {
await getSession(); try {
const { rows } = await pool.query(
`SELECT id, user_id, company_name, contact_name, email, phone, account_status, role, brand_id
FROM wholesale_customers
@@ -159,7 +165,8 @@ export async function getWholesaleCustomer(
}
export async function getWholesaleProducts(brandId: string) {
// Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
await getSession(); // Uses SECURITY DEFINER RPC — no auth required for admins, anon key works for customers too
try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_products($1)",
@@ -190,7 +197,8 @@ export async function submitWholesaleOrder(params: {
orderTotal?: number;
idempotent?: boolean;
}> {
// Generate UUID at the start of checkout — before RPC call for true idempotency
await getSession(); // Generate UUID at the start of checkout — before RPC call for true idempotency
const checkoutSessionId = params.checkoutSessionId ?? crypto.randomUUID();
const itemsJson = params.items.map(i => ({
@@ -259,7 +267,8 @@ export async function submitWholesaleOrder(params: {
}
export async function enqueueWholesaleWebhookForOrderCreated(orderId: string, brandId: string, customerId: string, invoiceNumber: string | null, subtotal: number) {
try {
await getSession(); try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
@@ -314,7 +323,8 @@ export type WholesalePricingOverride = {
};
export async function getWholesaleCustomerPricing(customerId: string): Promise<WholesalePricingOverride[]> {
try {
await getSession(); try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_pricing($1)",
[customerId]
@@ -330,7 +340,8 @@ export async function upsertWholesaleCustomerPricing(params: {
productId: string;
customUnitPrice: number;
}): Promise<{ success: boolean; error?: string }> {
try {
await getSession(); try {
await pool.query(
"SELECT upsert_wholesale_customer_pricing($1, $2, $3)",
[params.customerId, params.productId, params.customUnitPrice]
@@ -345,7 +356,8 @@ export async function deleteWholesaleCustomerPricing(params: {
customerId: string;
productId: string;
}): Promise<{ success: boolean; error?: string }> {
try {
await getSession(); try {
await pool.query(
"SELECT delete_wholesale_customer_pricing($1, $2)",
[params.customerId, params.productId]
@@ -357,7 +369,8 @@ export async function deleteWholesaleCustomerPricing(params: {
}
export async function getWholesaleCustomerOrders(customerId: string): Promise<WholesaleCustomerOrder[]> {
try {
await getSession(); try {
const { rows } = await pool.query(
"SELECT * FROM get_wholesale_customer_orders($1)",
[customerId]
+915
View File
@@ -0,0 +1,915 @@
"use server";
import { getActiveBrandId } from "@/lib/brand-scope";
import { pool } from "@/lib/db";
import { requireAuth } from "@/lib/admin-permissions";
import { getSession } from "@/lib/auth";
export type WholesaleOrder = {
id: string;
customer_id: string;
status: string;
fulfillment_status: string;
payment_status: string;
anticipated_pickup_date: string | null;
subtotal: number;
deposit_required: number;
deposit_paid: number;
balance_due: number;
created_at: string;
updated_at: string;
invoice_number: string | null;
assigned_employee_id: string | null;
company_name: string;
contact_name: string | null;
customer_email: string;
customer_phone: string | null;
items: Array<{
id: string;
product_name: string;
quantity: number;
unit_price: number;
line_total: number;
}>;
fulfilled_at: string | null;
};
export type WholesaleCustomer = {
id: string;
user_id: string | null;
company_name: string;
contact_name: string | null;
email: string;
phone: string | null;
account_status: string;
credit_limit: number;
deposits_enabled: boolean;
deposit_threshold: number | null;
deposit_percentage: number | null;
order_email: string | null;
invoice_email: string | null;
admin_notes: string | null;
role: string;
created_at: string;
deleted_at: string | null;
};
export type WholesaleProduct = {
id: string;
name: string;
description: string | null;
unit_type: string;
unit_type_custom: string | null;
availability: string;
qty_available: number;
season_start: string | null;
season_end: string | null;
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
hp_sku: string | null;
hp_item_id: string | null;
handling_instructions: string | null;
storage_warning: string | null;
loading_notes: string | null;
product_label: string | null;
pack_style: string | null;
container_type: string | null;
container_size_code: string | null;
units_per_container: number | null;
default_pickup_location: string | null;
created_at: string;
deleted_at: string | null;
};
export type NotificationRecipient = {
email: string;
name?: string;
active: boolean;
notification_types?: (
| "order_confirmation"
| "deposit_received"
| "order_fulfilled"
| "price_sheet"
| "unclaimed_pickup"
)[];
};
export type WholesaleSettings = {
id: string;
brand_id: string;
portal_page_id: string | null;
price_sheet_page_id: string | null;
require_approval: boolean;
min_order_amount: number | null;
online_payment_enabled: boolean;
wholesale_enabled: boolean;
square_sync_enabled: boolean;
pickup_location: string;
fob_location: string;
from_email: string;
invoice_business_name: string;
invoice_business_address: string | null;
invoice_business_phone: string | null;
invoice_business_email: string | null;
invoice_business_website: string | null;
notification_email: string | null;
notification_recipients: NotificationRecipient[];
last_invoice_number: number;
};
export type WholesaleDashboardStats = {
open_orders: number;
pickup_today: number;
past_due: number;
total_unpaid: number;
awaiting_deposit: number;
fulfilled_today: number;
};
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
*
* platform_admin → null (means "all brands" — passes to RPC unchanged)
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
* store_employee → their own brand_id
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
* unauthenticated → null (actions should already bail out earlier)
*
* This prevents brand_admin from seeing or modifying another brand's data
* even if they manually pass a different brandId to the action.
*/
async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof requireAuth>>,
requestedBrandId?: string
): Promise<string | null> {
if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
return null;
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
// Brand admin trying to operate on another brand's data — block it
return null; // caller should check and return unauthorized
}
return activeBrandId;
}
/**
* Like resolveBrandId but returns null for platform_admin AND throws an error
* if a brand_admin tries to operate outside their brand.
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
*/
async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof requireAuth>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
}
// For non-platform-admin: resolve the active brand (validates against brand_ids)
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
if (requestedBrandId && activeBrandId !== requestedBrandId) {
return { brandId: null, error: "Not authorized to operate on this brand" };
}
return { brandId: activeBrandId };
}
// ── Orders ──────────────────────────────────────────────────────────────────
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await requireAuth()
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await requireAuth()
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleOrder>(
"SELECT * FROM get_wholesale_pickup_orders($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
await getSession();
const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
return {
open_orders: open.length,
pickup_today: pickupToday.length,
past_due: pastDue.length,
total_unpaid: totalUnpaid,
awaiting_deposit: awaitingDep.length,
fulfilled_today: fulfilledToday.length,
};
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query(
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
[orderId, adminUser.user_id]
);
if (!rows || rows.length === 0) {
return { success: false, error: "Failed to mark fulfilled" };
}
} catch {
return { success: false, error: "Failed to mark fulfilled" };
}
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"order_fulfilled",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId }),
]
);
} catch (_) {}
}
export async function updateWholesaleOrderStatus(
orderId: string,
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM update_wholesale_order_status($1, $2)",
[orderId, status]
);
return { success: true };
} catch {
return { success: false, error: "Failed to update order status" };
}
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
await pool.query(
"SELECT * FROM delete_wholesale_order($1)",
[orderId]
);
return { success: true };
} catch {
return { success: false, error: "Failed to delete order" };
}
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_customer($1)",
[customerId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
}
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM delete_wholesale_product($1)",
[productId]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Delete failed" };
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
}
}
// ── Customers ────────────────────────────────────────────────────────────────
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await requireAuth()
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleCustomer>(
"SELECT * FROM get_wholesale_customers($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleCustomer(params: {
brandId: string;
userId?: string;
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
accountStatus?: string;
creditLimit?: number;
depositsEnabled?: boolean;
depositThreshold?: number;
depositPercentage?: number;
orderEmail?: string;
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{
success: boolean; error?: string; id?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
[
params.brandId,
params.userId ?? null,
params.companyName ?? null,
params.contactName ?? null,
params.email ?? null,
params.phone ?? null,
params.accountStatus ?? "active",
params.creditLimit ?? 0,
params.depositsEnabled ?? false,
params.depositThreshold ?? null,
params.depositPercentage ?? null,
params.orderEmail ?? null,
params.invoiceEmail ?? null,
params.adminNotes ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save customer" };
}
}
// ── Products ─────────────────────────────────────────────────────────────────
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await requireAuth()
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
try {
const { rows } = await pool.query<WholesaleProduct>(
"SELECT * FROM get_wholesale_products($1)",
[bid]
);
return rows;
} catch {
return [];
}
}
export async function saveWholesaleProduct(params: {
brandId: string;
id?: string;
name: string;
description?: string;
unitType?: string;
availability?: string;
qtyAvailable?: number;
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
hpSku?: string;
hpItemId?: string;
internalNotes?: string;
handlingInstructions?: string;
storageWarning?: string;
productLabel?: string;
packStyle?: string;
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{
success: boolean; error?: string; id?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
const { error } = await enforceBrandScope(adminUser, params.brandId);
if (error) return { success: false, error };
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
[
params.brandId,
params.id ?? null,
params.name,
params.description ?? null,
params.unitType ?? "each",
params.availability ?? "unavailable",
params.qtyAvailable ?? 0,
JSON.stringify(params.priceTiers ?? []),
params.hpSku ?? null,
params.hpItemId ?? null,
params.internalNotes ?? null,
params.handlingInstructions ?? null,
params.storageWarning ?? null,
params.productLabel ?? null,
params.packStyle ?? null,
params.containerType ?? null,
params.defaultPickupLocation ?? null,
]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, id: data?.id };
} catch {
return { success: false, error: "Failed to save product" };
}
}
// ── Settings ─────────────────────────────────────────────────────────────────
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await requireAuth()
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
return null;
}
try {
const { rows } = await pool.query<WholesaleSettings>(
"SELECT * FROM get_wholesale_settings($1)",
[bid]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{
invoice_business_address: string | null } | null> {
await getSession();
try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWholesaleSettings(params: {
brandId: string;
requireApproval?: boolean;
minOrderAmount?: number;
onlinePaymentEnabled?: boolean;
wholesaleEnabled?: boolean;
squareSyncEnabled?: boolean;
pickupLocation?: string;
fobLocation?: string;
fromEmail?: string;
invoiceBusinessName?: string;
invoiceBusinessAddress?: string;
invoiceBusinessPhone?: string;
invoiceBusinessEmail?: string;
invoiceBusinessWebsite?: string;
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
[
params.brandId,
params.requireApproval ?? null,
params.minOrderAmount ?? null,
params.onlinePaymentEnabled ?? null,
params.wholesaleEnabled ?? null,
params.pickupLocation ?? null,
params.fobLocation ?? null,
params.fromEmail ?? null,
params.invoiceBusinessName ?? null,
params.invoiceBusinessAddress ?? null,
params.invoiceBusinessPhone ?? null,
params.invoiceBusinessEmail ?? null,
params.invoiceBusinessWebsite ?? null,
params.notificationEmail ?? null,
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
params.squareSyncEnabled ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save settings" };
}
}
// ── Deposits ─────────────────────────────────────────────────────────────────
export async function recordWholesaleDeposit(
orderId: string,
amount: number,
method: string = "cash",
reference?: string
): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
const activeBrandId = await getActiveBrandId(adminUser);
let data: { success?: boolean; error?: string } | null = null;
try {
const { rows } = await pool.query<{ success: boolean; error?: string }>(
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
);
data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to record deposit" };
}
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
}
// Fire webhook — fire-and-forget
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
return { success: true };
}
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
try {
await pool.query(
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
[
"deposit_recorded",
orderId,
brandId ?? null,
JSON.stringify({ order_id: orderId, amount }),
]
);
} catch (_) {}
}
// ── Bulk Actions ──────────────────────────────────────────────────────────────
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{
success: boolean; count?: number; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
}
}
export async function bulkRecordWholesaleDeposit(
orderIds: string[],
amount: number,
method: string = "cash",
reference?: string
): Promise<{
success: boolean; count?: number; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
);
const data = Array.isArray(rows) ? rows[0] : rows;
if (!data?.success) {
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
}
return { success: true, count: data.count };
} catch (e: unknown) {
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
}
}
// ── Notifications ─────────────────────────────────────────────────────────────
export type WholesaleNotification = {
id: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
email_to: string;
email_cc: string | null;
subject: string;
body_html: string | null;
body_text: string | null;
brand_id: string;
customer_id: string;
order_id: string | null;
status: string;
invoice_business_name: string | null;
invoice_business_email: string | null;
created_at: string;
};
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{
pending: number; sent: number; failed: number; total: number }> {
await getSession();
try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
);
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
} catch {
return { pending: 0, sent: 0, failed: 0, total: 0 };
}
}
export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
const adminUser = await requireAuth()
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
try {
const { rows } = await pool.query<WholesaleNotification>(
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{
success: boolean }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
try {
await pool.query(
"SELECT mark_wholesale_notification_sent($1, $2)",
[notificationId, error ?? null]
);
return { success: true };
} catch {
return { success: false };
}
}
export async function enqueueWholesaleNotification(params: {
brandId: string;
customerId: string;
orderId: string;
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
emailTo: string;
emailCc?: string;
subject: string;
bodyHtml?: string;
bodyText?: string;
}): Promise<{
success: boolean; error?: string }> {
await getSession();
try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
params.brandId,
params.customerId,
params.orderId,
params.type,
params.emailTo,
params.emailCc ?? null,
params.subject,
params.bodyHtml ?? null,
params.bodyText ?? null,
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to enqueue notification" };
}
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export type WebhookSettings = {
id: string;
brand_id: string;
url: string;
secret: string;
enabled: boolean;
created_at: string;
updated_at: string;
};
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
await getSession();
try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
return rows[0] ?? null;
} catch {
return null;
}
}
export async function saveWebhookSettings(params: {
brandId: string;
url?: string;
secret?: string;
enabled?: boolean;
}): Promise<{
success: boolean; error?: string }> {
const adminUser = await requireAuth()
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
try {
await pool.query(
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
[
JSON.stringify({
brand_id: params.brandId,
url: params.url ?? "",
secret: params.secret ?? "",
enabled: params.enabled ?? false,
}),
]
);
return { success: true };
} catch {
return { success: false, error: "Failed to save webhook settings" };
}
}
export async function enqueueWholesaleWebhook(
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
orderId: string | null = null,
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{
success: boolean; logId?: string }> {
await getSession();
try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
);
const data = Array.isArray(rows) ? rows[0] : rows;
return { success: true, logId: data?.id };
} catch {
return { success: false };
}
}
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>> {
await getSession();
try {
const { rows } = await pool.query<{
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
}>(
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
[brandId, limit]
);
return rows;
} catch {
return [];
}
}
+7 -3
View File
@@ -4,9 +4,11 @@ import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleCustomer } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
@@ -37,7 +39,8 @@ export async function saveWholesaleCustomer(params: {
invoiceEmail?: string;
adminNotes?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -72,7 +75,8 @@ export async function saveWholesaleCustomer(params: {
}
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+7 -3
View File
@@ -4,6 +4,7 @@ import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import { getSession } from "@/lib/auth";
export async function recordWholesaleDeposit(
orderId: string,
@@ -12,7 +13,8 @@ export async function recordWholesaleDeposit(
reference?: string,
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -40,7 +42,8 @@ export async function recordWholesaleDeposit(
export async function bulkFulfillWholesaleOrders(
orderIds: string[]
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -65,7 +68,8 @@ export async function bulkRecordWholesaleDeposit(
method: string = "cash",
reference?: string
): Promise<{ success: boolean; count?: number; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+9 -4
View File
@@ -3,11 +3,13 @@
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WholesaleNotification } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleNotificationStats(
brandId: string
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
try {
await getSession(); try {
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
"SELECT * FROM get_wholesale_notification_stats($1)",
[brandId]
@@ -22,7 +24,8 @@ export async function getWholesalePendingNotifications(
brandId: string,
limit = 50
): Promise<WholesaleNotification[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
if (!adminUser.can_manage_orders) return [];
@@ -41,7 +44,8 @@ export async function markWholesaleNotificationSent(
notificationId: string,
error?: string
): Promise<{ success: boolean }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false };
if (!adminUser.can_manage_orders) return { success: false };
@@ -67,7 +71,8 @@ export async function enqueueWholesaleNotification(params: {
bodyHtml?: string;
bodyText?: string;
}): Promise<{ success: boolean; error?: string }> {
try {
await getSession(); try {
await pool.query(
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
[
+13 -6
View File
@@ -6,9 +6,11 @@ import { getActiveBrandId } from "@/lib/brand-scope";
import { enforceBrandScope, resolveBrandId } from "./scope";
import { enqueueWholesaleWebhook } from "./webhooks";
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
@@ -24,7 +26,8 @@ export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrd
}
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
@@ -40,7 +43,8 @@ export async function getWholesalePickupOrders(brandId?: string): Promise<Wholes
}
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
const orders = await getWholesaleOrders(brandId);
await getSession(); const orders = await getWholesaleOrders(brandId);
const today = new Date().toISOString().split("T")[0];
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
@@ -61,7 +65,8 @@ export async function getWholesaleDashboardStats(brandId?: string): Promise<Whol
}
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -90,7 +95,8 @@ export async function updateWholesaleOrderStatus(
status: "pending" | "confirmed" | "cancelled",
brandId?: string
): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -109,7 +115,8 @@ export async function updateWholesaleOrderStatus(
}
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+7 -3
View File
@@ -4,9 +4,11 @@ import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { enforceBrandScope, resolveBrandId } from "./scope";
import type { WholesaleProduct } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return [];
const bid = await resolveBrandId(adminUser, brandId);
@@ -40,7 +42,8 @@ export async function saveWholesaleProduct(params: {
containerType?: string;
defaultPickupLocation?: string;
}): Promise<{ success: boolean; error?: string; id?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
@@ -78,7 +81,8 @@ export async function saveWholesaleProduct(params: {
}
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
+5 -2
View File
@@ -7,6 +7,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getSession } from "@/lib/auth";
/**
* Resolves the effective brand_id for an action, enforcing brand scoping.
@@ -24,7 +25,8 @@ export async function resolveBrandId(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<string | null> {
if (!adminUser) return null;
await getSession(); if (!adminUser) return null;
if (adminUser.role === "platform_admin") {
// platform_admin can operate on all brands — pass null (= all brands) to RPC
@@ -51,7 +53,8 @@ export async function enforceBrandScope(
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
requestedBrandId?: string
): Promise<{ brandId: string | null; error?: string }> {
if (!adminUser) return { brandId: null, error: "Not authenticated" };
await getSession(); if (!adminUser) return { brandId: null, error: "Not authenticated" };
if (adminUser.role === "platform_admin") {
return { brandId: null }; // unrestricted
+7 -3
View File
@@ -4,9 +4,11 @@ import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import type { NotificationRecipient, WholesaleSettings } from "./types";
import { getSession } from "@/lib/auth";
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return null;
const bid = await getActiveBrandId(adminUser, brandId);
if (!bid && adminUser.role !== "platform_admin") {
@@ -25,7 +27,8 @@ export async function getWholesaleSettings(brandId?: string): Promise<WholesaleS
}
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
try {
await getSession(); try {
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
[brandId]
@@ -54,7 +57,8 @@ export async function saveWholesaleSettings(params: {
notificationEmail?: string;
notificationRecipients?: NotificationRecipient[];
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
+9 -4
View File
@@ -3,9 +3,11 @@
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
import type { WebhookSettings } from "./types";
import { getSession } from "@/lib/auth";
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
try {
await getSession(); try {
const { rows } = await pool.query<WebhookSettings>(
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
@@ -22,7 +24,8 @@ export async function saveWebhookSettings(params: {
secret?: string;
enabled?: boolean;
}): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
await getSession(); const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
@@ -50,7 +53,8 @@ export async function enqueueWholesaleWebhook(
payload: Record<string, unknown> | null = null,
brandId?: string
): Promise<{ success: boolean; logId?: string }> {
try {
await getSession(); try {
const { rows } = await pool.query<{ id: string }>(
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
@@ -71,7 +75,8 @@ export async function getRecentWebhookActivity(brandId: string, limit = 10): Pro
created_at: string;
response: string | null;
}>> {
try {
await getSession(); try {
const { rows } = await pool.query<{
id: string;
event_type: string;
+1 -1
View File
@@ -60,7 +60,7 @@ export default function AdminErrorPage({
</p>
)}
<div className="flex items-center justify-center gap-3 mt-6">
<button
<button type="button"
onClick={reset}
className="rounded-xl px-5 py-2.5 text-sm font-medium border transition-all hover:-translate-y-0.5"
style={{
+11 -11
View File
@@ -162,7 +162,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
{isPlatformAdmin && (
<div className="mb-6 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)] p-4">
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Importing to brand</label>
<select
<select aria-label="Select"
value={activeBrandId}
onChange={(e) => handleBrandChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)]"
@@ -189,7 +189,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">or click to browse</p>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">CSV, XLSX, XLS, TXT · max 5,000 rows · 10MB</p>
<input ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
<input aria-label="File upload" ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
</div>
{fileName && (
@@ -201,7 +201,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<p className="text-sm font-medium text-green-700 truncate">{fileName}</p>
<p className="text-xs text-green-600">Ready to analyze</p>
</div>
<button onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-600 font-medium hover:text-green-700">Change</button>
<button type="button" onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-600 font-medium hover:text-green-700">Change</button>
</div>
)}
@@ -209,7 +209,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{analysisError}</div>
)}
<button
<button type="button"
onClick={handleAnalyze}
disabled={!base64Data}
className="w-full rounded-xl bg-[var(--admin-accent)] px-6 py-3.5 text-base font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -265,7 +265,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<p className="text-xs text-[var(--admin-text-muted)] mb-1">Is this wrong?</p>
<div className="flex gap-1">
{(["products", "orders", "stops", "contacts"] as ImportEntityType[]).map((t) => (
<button key={t} onClick={() => overrideType(t)} className={`text-xs px-2 py-1 rounded-lg border transition-colors ${
<button type="button" key={t} onClick={() => overrideType(t)} className={`text-xs px-2 py-1 rounded-lg border transition-colors ${
t === analysis.detectedType ? "bg-[var(--admin-accent)] text-white border-[var(--admin-accent)]" : "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
}`}>{ENTITY_LABELS[t]}</button>
))}
@@ -280,7 +280,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4">
<p className="text-sm font-medium text-amber-700 mb-2">Issues detected</p>
<ul className="text-xs text-amber-600 space-y-1">
{analysis.warnings.slice(0, 5).map((w, i) => <li key={i}> {w}</li>)}
{analysis.warnings.slice(0, 5).map((w) => <li key={w}> {w}</li>)}
{analysis.warnings.length > 5 && <li>...and {analysis.warnings.length - 5} more</li>}
</ul>
</div>
@@ -310,7 +310,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<tr key={header} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg)]">
<td className="px-5 py-2.5 font-medium text-[var(--admin-text-primary)] whitespace-nowrap">{header}</td>
<td className="px-5 py-2.5">
<select
<select aria-label="Select"
value={mappedField ?? ""}
onChange={(e) => updateMapping(header, e.target.value)}
className="rounded-lg border border-[var(--admin-border)] bg-white px-2 py-1 text-xs text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)]"
@@ -364,10 +364,10 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
{/* Actions */}
<div className="flex gap-3">
<button onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-3 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
<button type="button" onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-3 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
Upload Different File
</button>
<button
<button type="button"
onClick={handleImport}
disabled={!analysis.detectedType || analysis.detectedType === "unknown" || importing}
className="flex-1 rounded-xl bg-[var(--admin-accent)] px-6 py-3 text-base font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
@@ -394,7 +394,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<p className="mt-2 text-sm text-amber-600">{importResult.errors.length} rows had errors</p>
)}
<div className="mt-6 flex gap-3 justify-center">
<button onClick={() => { setStep("upload"); setAnalysis(null); setFileName(""); setBase64Data(""); setImportResult(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
<button type="button" onClick={() => { setStep("upload"); setAnalysis(null); setFileName(""); setBase64Data(""); setImportResult(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
Import Another
</button>
<a href={analysis?.detectedType === "products" ? "/admin/products" : analysis?.detectedType === "orders" ? "/admin/orders" : "/admin"} className="rounded-xl bg-[var(--admin-accent)] px-6 py-2.5 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)]">
@@ -406,7 +406,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
<div className="rounded-2xl bg-[var(--admin-bg)] border border-red-200 p-8 text-center">
<h2 className="text-xl font-bold text-[var(--admin-text-primary)] mb-2">Import Failed</h2>
<p className="text-[var(--admin-text-secondary)]">{importError ?? "An error occurred during import."}</p>
<button onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
<button type="button" onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
Back to Preview
</button>
</div>
+4 -4
View File
@@ -108,7 +108,7 @@ export default function LaunchChecklistPage() {
<div className="h-6 w-px bg-[#e5e5e5]" />
<h1 className="text-lg font-bold text-[#1a1a1a]">Launch Checklist</h1>
</div>
<button className="px-4 py-2 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-white transition-colors">
<button type="button" className="px-4 py-2 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-white transition-colors">
Export PDF
</button>
</div>
@@ -159,7 +159,7 @@ export default function LaunchChecklistPage() {
{/* Quick Actions */}
<div className="flex gap-3">
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
<button type="button" className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
Mark All Complete
</button>
</div>
@@ -187,7 +187,7 @@ export default function LaunchChecklistPage() {
<div className="divide-y divide-[#f0f0f0]">
{category.items.map((item) => (
<div key={item.id} className="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors">
<button className="w-6 h-6 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-[#1a4d2e] transition-colors">
<button type="button" className="w-6 h-6 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-[#1a4d2e] transition-colors">
<CheckCircle2 className="w-4 h-4 text-emerald-500 opacity-0" />
</button>
<div className="flex-1">
@@ -208,7 +208,7 @@ export default function LaunchChecklistPage() {
<div className="mt-12 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] rounded-2xl p-8 text-center text-white">
<h2 className="text-2xl font-bold mb-2">Ready to launch?</h2>
<p className="text-white/80 mb-6">When all items are complete, you&apos;re ready to go live!</p>
<button className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
<button type="button" className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
Mark Launch Complete
</button>
</div>
+4 -4
View File
@@ -89,7 +89,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</div>
</div>
{!editing && (
<button
<button type="button"
onClick={() => setEditing(true)}
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
style={{
@@ -123,7 +123,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
<div>
<label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
<input
<input aria-label="Your Name"
id="me-display-name"
type="text"
value={displayName}
@@ -139,7 +139,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
</div>
<div>
<label htmlFor="me-phone" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
<input
<input aria-label="+1 (555) 000 0000"
id="me-phone"
type="tel"
value={phoneNumber}
@@ -201,7 +201,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
<div>
<label htmlFor="me-new-email" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
<input
<input aria-label="New@example.com"
id="me-new-email"
type="email"
value={newEmail}
+106
View File
@@ -0,0 +1,106 @@
import { Suspense } from "react";
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { getAdminOrders } from "@/actions/orders";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
export const dynamic = "force-dynamic";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders) {
redirect("/admin/pickup");
}
const activeBrandId = await getActiveBrandId(adminUser);
// Platform admin can browse all brands' orders; everyone else must have a brand
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied message="You don't have access to any brand." />;
}
const { orders, stops } = await getAdminOrders();
const brandStops = activeBrandId
? stops.filter((s) => s.brand_id === activeBrandId)
: stops;
const brandOrders = activeBrandId
? orders.filter(
(o) =>
o.stops && brandStops.some((s) => s.id === o.stop_id)
)
: orders;
// Fetch active products for this brand (for admin "New Order" item picker)
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
try {
let prodQuery = supabase
.from("products")
.select("id, name, price, type, active")
.eq("active", true)
.is("deleted_at", null)
.order("name")
.limit(200);
if (activeBrandId) {
prodQuery = prodQuery.eq("brand_id", activeBrandId);
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p) => ({
id: String(p.id),
name: String(p.name ?? ""),
price: Number(p.price),
type: p.type as string | null ?? null,
active: Boolean(p.active ?? true),
}));
} catch {
// non-fatal for the orders list
}
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<p className="ha-eyebrow mb-2">Operations</p>
<PageHeader
title="Orders"
subtitle="View, filter, and manage customer orders."
icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
}
/>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div
className="rounded-2xl border overflow-hidden"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<Suspense fallback={null}>
<AdminOrdersPanel
initialOrders={brandOrders}
initialStops={brandStops}
initialProducts={brandProducts}
brandId={activeBrandId}
/>
</Suspense>
</div>
</div>
</div>
);
}
+9 -5
View File
@@ -32,7 +32,7 @@ export default async function AdminPage() {
// so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
// Direct pg query (the legacy query-builder shim returned empty results).
// Direct pg query (the supabase shim returns empty results).
// This ensures a platform_admin sees real dashboard stats even on first login
// before they have chosen an active brand.
try {
@@ -62,7 +62,7 @@ export default async function AdminPage() {
enabledAddons = o.enabledAddons;
} else {
// Fallback to per-feature flag check (matches prior behavior)
for (const key of [
const featureKeys = [
"harvest_reach",
"wholesale_portal",
"water_log",
@@ -70,9 +70,13 @@ export default async function AdminPage() {
"sms_campaigns",
"square_sync",
"route_trace",
] as const) {
enabledAddons[key] = await isFeatureEnabled(dashboardBrandId, key);
}
] as const;
const featureFlags = await Promise.all(
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
);
featureKeys.forEach((key, i) => {
enabledAddons[key] = featureFlags[i];
});
}
}
+4 -2
View File
@@ -9,8 +9,10 @@ const PICKUP_WINDOW_MS = 72 * 60 * 60 * 1000;
const pickedUpCutoff = new Date(Date.now() - PICKUP_WINDOW_MS);
export default async function DriverPickupPage() {
const adminUser = await getAdminUser();
const { orders, stops } = await getAdminOrders();
const [adminUser, { orders, stops }] = await Promise.all([
getAdminUser(),
getAdminOrders(),
]);
// Filter stops by brand if scoped
const brandStops = adminUser?.brand_id
+161
View File
@@ -0,0 +1,161 @@
import { supabase } from "@/lib/supabase";
import ProductEditForm from "@/components/admin/ProductEditForm";
import { getAdminUser } from "@/lib/admin-permissions";
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
import { Package as PackageIcon } from "lucide-react";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { redirect } from "next/navigation";
import Link from "next/link";
type ProductDetailPageProps = {
params: Promise<{
id: string;
}>;
};
interface Product {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
active: boolean;
type: string | null;
image_url: string | null;
is_taxable: boolean;
pickup_type: string | null;
brands?: { name: string; slug: string };
}
interface Brand {
id: string;
name: string;
slug: string;
}
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;
const [
{ data: product, error },
{ data: brands },
adminUser,
] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as Promise<{ data: Product | null; error: { message: string } | null }>,
supabase.from("brands").select("id, name, slug") as unknown as Promise<{ data: Brand[] | null }>,
getAdminUser(),
]);
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_products) redirect("/admin/pickup");
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
return <AdminAccessDenied />;
}
if (error || !product) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
{error?.message ?? "Product not found"}
</pre>
<Link
href="/admin/products"
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
>
Back to Products
</Link>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-4xl">
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
title="Edit product"
subtitle="Update product details, pricing, and availability."
actions={
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
{product.active ? "Active" : "Inactive"}
</AdminBadge>
}
/>
<div className="mb-6">
<Link
href="/admin/products"
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Back to Products
</Link>
</div>
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
{product.brands?.name}
</p>
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
{product.name}
</h1>
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
{product.description}
</p>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
<p
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
${Number(product.price).toFixed(2)}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
{product.type}
</p>
</div>
</div>
</div>
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
<p className="mt-1 text-[var(--admin-text-muted)]">
Update product details, pricing, and availability.
</p>
<div className="mt-6">
<ProductEditForm
product={{
id: product.id,
name: product.name,
description: product.description ?? "",
price: Number(product.price),
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
</div>
</div>
</div>
</main>
);
}
+7 -7
View File
@@ -93,7 +93,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
Upload a CSV file to bulk-create or update products. Matching by product name within brand.
</p>
</div>
<button
<button type="button"
onClick={() => {
setCsvText(SAMPLE_CSV);
handlePreview(SAMPLE_CSV);
@@ -107,7 +107,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* Brand ID */}
<div className="mb-4">
<label htmlFor="import-products-brand" className="block text-sm font-medium text-stone-700">Brand ID</label>
<input
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de (Tuxedo)"
id="import-products-brand"
type="text"
value={brandId}
@@ -120,7 +120,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* File upload */}
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
<label htmlFor="import-products-csv" className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
<input
<input aria-label="Import Products Csv"
id="import-products-csv"
ref={fileRef}
type="file"
@@ -132,7 +132,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
Or paste CSV content below:
</p>
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
<textarea
<textarea aria-label="Name,description,price,type,active,image Url"
id="import-products-text"
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
@@ -140,7 +140,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
placeholder="name,description,price,type,active,image_url"
/>
<button
<button type="button"
onClick={() => handlePreview()}
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
>
@@ -169,7 +169,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
<h2 className="text-lg font-semibold text-stone-950">
Preview ({preview.length} rows)
</h2>
<button
<button type="button"
onClick={handleImport}
disabled={!brandId || importing}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
@@ -227,7 +227,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{result.errors.length > 0 && (
<ul className="mt-3 space-y-1">
{result.errors.map((e, i) => (
<li key={i} className="text-sm text-red-600">
<li key={`${e.product}-${i}`} className="text-sm text-red-600">
{e.product && `${e.product}: `}{e.error}
</li>
))}
+42
View File
@@ -0,0 +1,42 @@
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import ReportsDashboard from "@/components/admin/ReportsDashboard";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
export default async function ReportsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
const isPlatformAdmin = adminUser.role === "platform_admin";
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name") as unknown as { data: { id: string; name: string }[] | null };
const initialBrandId = isPlatformAdmin
? null
: await getActiveBrandId(adminUser);
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-7xl">
<PageHeader
title="Reports"
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
/>
<ReportsDashboard
brands={brands ?? []}
initialBrandId={initialBrandId}
isPlatformAdmin={isPlatformAdmin}
brandId={adminUser.brand_id}
/>
</div>
</main>
);
}
+1 -2
View File
@@ -21,8 +21,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
}
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const adminUser = await getAdminUser();
const [{ id }, adminUser] = await Promise.all([params, getAdminUser()]);
if (!adminUser) redirect("/login");
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
+6 -6
View File
@@ -91,7 +91,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
<div className="mb-4">
<label htmlFor="import-orders-brand" className="block text-sm font-medium text-stone-600">Brand ID</label>
<input
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de"
id="import-orders-brand"
type="text"
value={brandId}
@@ -103,17 +103,17 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
<div className="mb-4 card p-6">
<label htmlFor="import-orders-csv" className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
<input id="import-orders-csv" ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
<input aria-label="Import Orders Csv" id="import-orders-csv" ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
<p className="mt-2 text-xs text-stone-500">Paste CSV content below:</p>
<label htmlFor="import-orders-text" className="sr-only">CSV text</label>
<textarea
<textarea aria-label="Import Orders Text"
id="import-orders-text"
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
rows={6}
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500 bg-white"
/>
<button
<button type="button"
onClick={() => handlePreview()}
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
>
@@ -136,7 +136,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
<div className="mb-4 card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-stone-950">Preview ({preview.length} rows)</h2>
<button
<button type="button"
onClick={handleImport}
disabled={!brandId || importing}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-700"
@@ -181,7 +181,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
{result.errors.length > 0 && (
<ul className="mt-3 space-y-1">
{result.errors.map((e, i) => (
<li key={i} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
<li key={`row-${e.row}-${i}`} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
))}
</ul>
)}
+127 -107
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AdminCard } from "@/components/admin/design-system";
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
@@ -96,7 +96,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
</div>
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--admin-border-light)' }}>
{tool.status === "available" && (
<button
<button type="button"
onClick={() => isConnected && onOpen(tool)}
disabled={!isConnected}
className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-all"
@@ -113,7 +113,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
</button>
)}
{tool.status === "coming_soon" && (
<button disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
<button type="button" disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
style={{
background: 'rgba(0, 0, 0, 0.04)',
color: 'rgba(0, 0, 0, 0.4)',
@@ -122,7 +122,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
</button>
)}
{tool.status === "experimental" && (
<button disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
<button type="button" disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
style={{
background: 'rgba(171, 162, 120, 0.15)',
color: '#92781e',
@@ -194,7 +194,7 @@ function ConnectionStatusBanner({ isConnected, availableCount, onConfigure }: {
<p className="font-semibold" style={{ color: '#92781e' }}>AI Not Configured</p>
<p className="text-sm" style={{ color: '#a6953f' }}>Add your API key to enable AI tools.</p>
</div>
<button
<button type="button"
onClick={onConfigure}
className="rounded-xl px-4 py-2 text-sm font-bold transition-all"
style={{
@@ -320,28 +320,33 @@ const PROVIDERS: ProviderOption[] = [
// ── Model Input ───────────────────────────────────────────────────────────────
function ModelInput({
currentModel,
function ModelInput({
currentModel,
brandId,
onChange
}: {
onChange
}: {
currentModel: string;
brandId: string;
onChange: (model: string) => void;
}) {
const [customModel, setCustomModel] = useState(currentModel);
// Uncontrolled input: the editable buffer is the DOM input itself.
// The parent uses `key={currentModel}` so that when the prop changes
// externally, this component fully remounts and `defaultValue` is
// re-applied from the new prop — no stale copy.
const inputRef = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
if (!customModel.trim()) return;
const value = inputRef.current?.value ?? "";
if (!value.trim()) return;
setSaving(true);
setError(null);
const result = await setAIProviderSettings(brandId, { model: customModel.trim() });
const result = await setAIProviderSettings(brandId, { model: value.trim() });
if (result.success) {
onChange(customModel.trim());
onChange(value.trim());
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
@@ -359,10 +364,11 @@ function ModelInput({
</h2>
</div>
<div className="flex gap-3">
<input
<input aria-label="., Gpt 4o, Claude 3 5 Sonnet 20241002"
ref={inputRef}
type="text"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
defaultValue={currentModel}
key={currentModel}
placeholder="e.g., gpt-4o, claude-3-5-sonnet-20241002"
className="flex-1 px-4 py-2.5 rounded-xl text-sm"
style={{
@@ -371,15 +377,15 @@ function ModelInput({
color: 'var(--admin-text-primary)',
}}
/>
<button
<button type="button"
onClick={handleSave}
disabled={saving || !customModel.trim()}
disabled={saving}
className="px-5 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
style={{
background: 'linear-gradient(135deg, #ca7543 0%, #d4865a 100%)',
boxShadow: '0 2px 8px rgba(202, 117, 67, 0.25)',
opacity: (saving || !customModel.trim()) ? 0.5 : 1,
cursor: (saving || !customModel.trim()) ? 'not-allowed' : 'pointer',
opacity: saving ? 0.5 : 1,
cursor: saving ? 'not-allowed' : 'pointer',
}}
>
{saving ? "..." : saved ? "✓" : "Save"}
@@ -421,7 +427,7 @@ function ProviderSelector({
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{PROVIDERS.map((provider) => (
<button
<button type="button"
key={provider.id}
onClick={() => handleSelect(provider.id)}
className="p-4 rounded-xl text-center transition-all"
@@ -515,7 +521,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
<div className="space-y-4">
<div>
<label htmlFor="ai-cw-topic" className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label>
<textarea
<textarea aria-label="., 'Remind Customers About The New Sweet Corn Season Starting Next Week, Emphasize Freshness And Local Delivery'"
id="ai-cw-topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
@@ -525,7 +531,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
style={textareaStyle}
/>
</div>
<button
<button type="button"
onClick={handleGenerate}
disabled={loading || !topic.trim()}
className={btnPrimaryClass}
@@ -541,7 +547,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
{results.length > 0 && (
<div className="space-y-3">
{results.map((idea, i) => (
<AdminCard key={i} className="p-4">
<AdminCard key={`idea-${i}-${idea.subject}`} className="p-4">
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-accent)' }}>Idea {i + 1}</p>
<p className="text-sm font-medium mb-1" style={{ color: 'var(--admin-text-primary)' }}><span style={{ color: 'var(--admin-text-muted)' }}>Subject:</span> {idea.subject}</p>
<p className="text-sm mt-2 whitespace-pre-line" style={{ color: 'var(--admin-text-secondary)', lineHeight: 1.6 }}>{idea.body}</p>
@@ -593,22 +599,22 @@ function ProductWriterTool({ brandId }: { brandId: string }) {
<label htmlFor="ai-pw-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
</label>
<input id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
<input aria-label="Sweet Corn" id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label htmlFor="ai-pw-category" className="block text-sm font-medium mb-1" style={labelStyle}>Category</label>
<input id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
<input aria-label="Vegetables" id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label htmlFor="ai-pw-price" className="block text-sm font-medium mb-1" style={labelStyle}>Price</label>
<input id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
<input aria-label="$4.50" id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label htmlFor="ai-pw-unit" className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label>
<input id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
<input aria-label="Per Dozen" id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
</div>
</div>
<button
<button type="button"
onClick={handleGenerate}
disabled={loading || !productName.trim()}
className={btnPrimaryClass}
@@ -698,7 +704,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="ai-rep-type" className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label>
<select
<select aria-label="Ai Rep Type"
id="ai-rep-type"
value={reportType}
onChange={(e) => setReportType(e.target.value)}
@@ -712,7 +718,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
</div>
<div>
<label htmlFor="ai-rep-range" className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label>
<input
<input aria-label="., Last 7 Days, March 2026"
id="ai-rep-range"
type="text"
value={dateRange}
@@ -726,7 +732,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
<button
<button type="button"
onClick={handleExplain}
disabled={loading}
className={btnPrimaryClass}
@@ -744,8 +750,8 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
<AdminCard className="p-5">
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-text-muted)' }}>Key Insights</p>
<ul className="space-y-2">
{result.keyInsights.map((insight, i) => (
<li key={i} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
{result.keyInsights.map((insight) => (
<li key={insight} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
<span style={{ color: 'var(--admin-accent)' }}></span>
<span>{insight}</span>
</li>
@@ -755,15 +761,15 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
<AdminCard className="p-5">
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-text-muted)' }}>Suggested Actions</p>
<ul className="space-y-2">
{result.suggestedActions.map((action, i) => (
<li key={i} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
{result.suggestedActions.map((action) => (
<li key={action} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
<span style={{ color: 'var(--admin-success)' }}></span>
<span>{action}</span>
</li>
))}
</ul>
</AdminCard>
<button
<button type="button"
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: 'var(--admin-text-muted)' }}
@@ -864,7 +870,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<label htmlFor="ai-pa-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
</label>
<input
<input aria-label="Sweet Corn"
id="ai-pa-product-name"
type="text"
value={productName}
@@ -881,11 +887,11 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium" style={labelStyle}>Price Tiers</label>
<button onClick={addTier} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Tier</button>
<button type="button" onClick={addTier} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Tier</button>
</div>
{priceTiers.map((tier, i) => (
<div key={i} className="flex items-center gap-2">
<input
<div key={`tier-${i}-${tier.tier}-${tier.price}`} className="flex items-center gap-2">
<input aria-label="., Wholesale"
type="text"
value={tier.tier}
onChange={(e) => updateTier(i, "tier", e.target.value)}
@@ -895,7 +901,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
/>
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm" style={{ color: 'var(--admin-text-muted)' }}>$</span>
<input
<input aria-label="0.00"
type="text"
value={tier.price}
onChange={(e) => updateTier(i, "price", e.target.value)}
@@ -905,7 +911,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
/>
</div>
{priceTiers.length > 1 && (
<button onClick={() => removeTier(i)} className="text-xs px-2 transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
<button type="button" onClick={() => removeTier(i)} className="text-xs px-2 transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
)}
</div>
))}
@@ -915,7 +921,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium" style={labelStyle}>Historical Sales</label>
<button onClick={addSale} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
<button type="button" onClick={addSale} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
</div>
<AdminCard className="divide-y" style={{ border: '1px solid var(--admin-border)' }}>
<div className="grid grid-cols-4 gap-2 px-3 py-2">
@@ -925,8 +931,8 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<span />
</div>
{historicalSales.map((sale, i) => (
<div key={i} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
<input
<div key={`sale-${i}-${sale.date}`} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
<input aria-label="2026 04 01"
type="text"
value={sale.date}
onChange={(e) => updateSale(i, "date", e.target.value)}
@@ -934,7 +940,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
className="rounded-lg border px-2 py-1.5 text-xs"
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
/>
<input
<input aria-label="120"
type="text"
value={sale.units_sold}
onChange={(e) => updateSale(i, "units_sold", e.target.value)}
@@ -944,7 +950,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
/>
<div className="relative">
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs" style={{ color: 'var(--admin-text-muted)' }}>$</span>
<input
<input aria-label="540"
type="text"
value={sale.revenue}
onChange={(e) => updateSale(i, "revenue", e.target.value)}
@@ -954,7 +960,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
/>
</div>
{historicalSales.length > 1 && (
<button onClick={() => removeSale(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
<button type="button" onClick={() => removeSale(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
)}
</div>
))}
@@ -963,7 +969,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
<button
<button type="button"
onClick={handleAnalyze}
disabled={loading || !productName.trim()}
className={btnPrimaryClass}
@@ -983,7 +989,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<p className="text-sm" style={{ color: 'var(--admin-text-secondary)', lineHeight: 1.6 }}>{result.currentState}</p>
</AdminCard>
{result.recommendations.map((rec, i) => (
<AdminCard key={i} className="p-5">
<AdminCard key={`rec-${i}-${rec.productName}-${rec.direction}`} className="p-5">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold" style={{ color: 'var(--admin-text-primary)' }}>{rec.productName}</p>
<span
@@ -1011,8 +1017,8 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Opportunities</p>
<ul className="space-y-1">
{result.opportunities.map((opp, i) => (
<li key={i} className="text-sm" style={{ color: '#059669' }}> {opp}</li>
{result.opportunities.map((opp) => (
<li key={opp} className="text-sm" style={{ color: '#059669' }}> {opp}</li>
))}
</ul>
</AdminCard>
@@ -1021,13 +1027,13 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
<ul className="space-y-1">
{result.warnings.map((warn, i) => (
<li key={i} className="text-sm" style={{ color: '#a6953f' }}> {warn}</li>
{result.warnings.map((warn) => (
<li key={warn} className="text-sm" style={{ color: '#a6953f' }}> {warn}</li>
))}
</ul>
</AdminCard>
)}
<button
<button type="button"
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: 'var(--admin-text-muted)' }}
@@ -1100,25 +1106,25 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Name *</label>
<input type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
<input aria-label="Downtown Farmers Market" type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Date</label>
<input type="text" value={stopDate} onChange={(e) => setStopDate(e.target.value)} placeholder="2026-06-15" className={inputBaseClass} style={inputStyle} />
<input aria-label="2026 06 15" type="text" value={stopDate} onChange={(e) => setStopDate(e.target.value)} placeholder="2026-06-15" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>City</label>
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Greeley" className={inputBaseClass} style={inputStyle} />
<input aria-label="Greeley" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Greeley" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Customer Count</label>
<input type="text" value={customerCount} onChange={(e) => setCustomerCount(e.target.value)} placeholder="42" className={inputBaseClass} style={inputStyle} />
<input aria-label="42" type="text" value={customerCount} onChange={(e) => setCustomerCount(e.target.value)} placeholder="42" className={inputBaseClass} style={inputStyle} />
</div>
</div>
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
<button
<button type="button"
onClick={handleAnalyze}
disabled={loading || !stopName.trim()}
className={btnPrimaryClass}
@@ -1140,7 +1146,7 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Recommended Subject Line</p>
<p className="text-base font-semibold" style={{ color: 'var(--admin-text-primary)' }}>{result.subjectLine}</p>
<button onClick={() => copyToClipboard(result.subjectLine)} className="mt-2 text-xs flex items-center gap-1 transition-colors" style={{ color: 'var(--admin-accent)' }}>
<button type="button" onClick={() => copyToClipboard(result.subjectLine)} className="mt-2 text-xs flex items-center gap-1 transition-colors" style={{ color: 'var(--admin-accent)' }}>
Copy
</button>
</AdminCard>
@@ -1159,7 +1165,7 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
</span>
</AdminCard>
{result.contentAngles.map((a, i) => (
<AdminCard key={i} className="p-4">
<AdminCard key={`angle-${i}-${a.angle}`} className="p-4">
<p className="text-sm font-semibold mb-1" style={{ color: 'var(--admin-text-primary)' }}>{a.angle}</p>
<p className="text-xs" style={{ color: 'var(--admin-text-muted)' }}>{a.reasoning}</p>
</AdminCard>
@@ -1167,8 +1173,8 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
{result.warnings.length > 0 && (
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
{result.warnings.map((w, i) => (
<p key={i} className="text-xs" style={{ color: '#a6953f' }}> {w}</p>
{result.warnings.map((w) => (
<p key={w} className="text-xs" style={{ color: '#a6953f' }}> {w}</p>
))}
</AdminCard>
)}
@@ -1230,7 +1236,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
</p>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Ask about your customers</label>
<textarea
<textarea aria-label="., 'Which Customers Haven't Ordered In 45 Days?'"
value={query}
onChange={(e) => setQuery(e.target.value)}
rows={2}
@@ -1241,7 +1247,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
</div>
<div className="flex flex-wrap gap-2">
{exampleQueries.map((q) => (
<button
<button type="button"
key={q}
onClick={() => { setQuery(q); handleAnalyze(q); }}
className="rounded-full px-3 py-1 text-xs transition-all"
@@ -1258,7 +1264,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
<button
<button type="button"
onClick={() => handleAnalyze()}
disabled={loading || !query.trim()}
className={btnPrimaryClass}
@@ -1300,13 +1306,16 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
</tr>
</thead>
<tbody>
{result.results.map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--admin-border-light)' }}>
{Object.values(row).map((v, j) => (
<td key={j} className="px-2 py-1.5" style={{ color: 'var(--admin-text-secondary)' }}>{String(v ?? "—")}</td>
{result.results.map((row) => {
const rowKey = Object.values(row).slice(0, 2).map(v => String(v ?? "")).join("-");
return (
<tr key={rowKey} style={{ borderBottom: '1px solid var(--admin-border-light)' }}>
{Object.entries(row).map(([colKey, v]) => (
<td key={colKey} className="px-2 py-1.5" style={{ color: 'var(--admin-text-secondary)' }}>{String(v ?? "—")}</td>
))}
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -1388,7 +1397,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</p>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Start Location</label>
<input
<input aria-label="Warehouse, Greeley CO"
type="text"
value={startLocation}
onChange={(e) => setStartLocation(e.target.value)}
@@ -1401,7 +1410,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium" style={labelStyle}>Stops</label>
<button
<button type="button"
onClick={addStop}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: 'var(--admin-accent)' }}
@@ -1410,11 +1419,11 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</button>
</div>
{stops.map((stop, i) => (
<AdminCard key={i} className="p-4 space-y-3">
<AdminCard key={`stop-${i}-${stop.name}-${stop.city}-${stop.state}`} className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--admin-text-muted)' }}>Stop {i + 1}</span>
{stops.length > 2 && (
<button onClick={() => removeStop(i)} className="text-xs transition-colors" style={{ color: 'var(--admin-danger)' }}>
<button type="button" onClick={() => removeStop(i)} className="text-xs transition-colors" style={{ color: 'var(--admin-danger)' }}>
Remove
</button>
)}
@@ -1422,7 +1431,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Name *</label>
<input
<input aria-label="Farmers Market"
type="text"
value={stop.name}
onChange={(e) => updateStop(i, "name", e.target.value)}
@@ -1433,7 +1442,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>City *</label>
<input
<input aria-label="Greeley"
type="text"
value={stop.city}
onChange={(e) => updateStop(i, "city", e.target.value)}
@@ -1444,7 +1453,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>State *</label>
<input
<input aria-label="CO"
type="text"
value={stop.state}
onChange={(e) => updateStop(i, "state", e.target.value)}
@@ -1455,7 +1464,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Address</label>
<input
<input aria-label="123 Main St"
type="text"
value={stop.address}
onChange={(e) => updateStop(i, "address", e.target.value)}
@@ -1467,7 +1476,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Time Window</label>
<input
<input aria-label="8am12pm"
type="text"
value={stop.time_window}
onChange={(e) => updateStop(i, "time_window", e.target.value)}
@@ -1482,7 +1491,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated suggestions review before use. Not a substitute for professional routing software.
</div>
<button
<button type="button"
onClick={handleOptimize}
disabled={loading || validStops.length < 2}
className={btnPrimaryClass}
@@ -1510,7 +1519,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Optimized Sequence</p>
{result.optimizedSequence.map((s, i) => (
<div key={i} className="flex items-start gap-3 py-2" style={{ borderBottom: i < result.optimizedSequence.length - 1 ? '1px solid var(--admin-border-light)' : 'none' }}>
<div key={`seq-${i}-${s.position}-${s.stopName}`} className="flex items-start gap-3 py-2" style={{ borderBottom: i < result.optimizedSequence.length - 1 ? '1px solid var(--admin-border-light)' : 'none' }}>
<span
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold text-white flex-shrink-0"
style={{ backgroundColor: 'var(--admin-accent)' }}
@@ -1528,16 +1537,16 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
{result.suggestions.length > 0 && (
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Suggestions</p>
{result.suggestions.map((s, i) => <p key={i} className="text-sm" style={{ color: '#059669' }}> {s}</p>)}
{result.suggestions.map((s) => <p key={s} className="text-sm" style={{ color: '#059669' }}> {s}</p>)}
</AdminCard>
)}
{result.warnings.length > 0 && (
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
{result.warnings.map((w, i) => <p key={i} className="text-sm" style={{ color: '#a6953f' }}> {w}</p>)}
{result.warnings.map((w) => <p key={w} className="text-sm" style={{ color: '#a6953f' }}> {w}</p>)}
</AdminCard>
)}
<button
<button type="button"
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: 'var(--admin-text-muted)' }}
@@ -1623,11 +1632,11 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label>
<input type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} />
<input aria-label="Sweet Corn" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Name</label>
<input type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
<input aria-label="Downtown Farmers Market" type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
</div>
</div>
@@ -1635,7 +1644,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium" style={labelStyle}>Historical Sales</label>
<button onClick={addRow} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
<button type="button" onClick={addRow} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
</div>
<AdminCard className="divide-y" style={{ border: '1px solid var(--admin-border)' }}>
<div className="grid grid-cols-4 gap-2 px-3 py-2">
@@ -1645,8 +1654,8 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
<span />
</div>
{historicalData.map((row, i) => (
<div key={i} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
<input
<div key={`hist-${i}-${row.date}-${row.stop}`} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
<input aria-label="2026 04 01"
type="text"
value={row.date}
onChange={(e) => updateRow(i, "date", e.target.value)}
@@ -1654,7 +1663,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
className="rounded-lg border px-2 py-1.5 text-xs"
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
/>
<input
<input aria-label="120"
type="text"
value={row.quantity_sold}
onChange={(e) => updateRow(i, "quantity_sold", e.target.value)}
@@ -1662,7 +1671,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
className="rounded-lg border px-2 py-1.5 text-xs"
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
/>
<input
<input aria-label="Farmers Market"
type="text"
value={row.stop}
onChange={(e) => updateRow(i, "stop", e.target.value)}
@@ -1671,7 +1680,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
/>
{historicalData.length > 1 && (
<button onClick={() => removeRow(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
<button type="button" onClick={() => removeRow(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}></button>
)}
</div>
))}
@@ -1680,7 +1689,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
AI-generated forecasts review before use. Not a substitute for professional supply chain planning.
</div>
<button
<button type="button"
onClick={handleAnalyze}
disabled={loading || !productName.trim()}
className={btnPrimaryClass}
@@ -1730,16 +1739,16 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
{result.seasonalFactors.length > 0 && (
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(59, 130, 246, 0.05)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#2563eb' }}>Seasonal Factors</p>
{result.seasonalFactors.map((f, i) => <p key={i} className="text-sm" style={{ color: '#3b82f6' }}> {f}</p>)}
{result.seasonalFactors.map((f) => <p key={f} className="text-sm" style={{ color: '#3b82f6' }}> {f}</p>)}
</AdminCard>
)}
{result.riskFlags.length > 0 && (
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Risk Flags</p>
{result.riskFlags.map((r, i) => <p key={i} className="text-sm" style={{ color: '#a6953f' }}> {r}</p>)}
{result.riskFlags.map((r) => <p key={r} className="text-sm" style={{ color: '#a6953f' }}> {r}</p>)}
</AdminCard>
)}
<button
<button type="button"
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: 'var(--admin-text-muted)' }}
@@ -1770,7 +1779,7 @@ function ToolModal({ tool, brandId, brandName, onClose }: ModalProps) {
<span className="text-2xl">{tool.icon}</span>
<h2 className="text-lg font-bold" style={{ color: 'var(--admin-text-primary)' }}>{tool.title}</h2>
</div>
<button onClick={onClose} className="p-2 transition-colors" style={{ color: 'var(--admin-text-muted)' }}>
<button type="button" onClick={onClose} className="p-2 transition-colors" style={{ color: 'var(--admin-text-muted)' }}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -1803,8 +1812,11 @@ export default function AIsettingsClient({
}: Props) {
const [activeTool, setActiveTool] = useState<AITool | null>(null);
const [activeModule, setActiveModule] = useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = useState<Provider>(provider);
const [currentModel, setCurrentModel] = useState(model);
// selectedProvider/currentModel are derived directly from props. We use a
// `key` on the child components so any prop change fully remounts them and
// their internal useState(initialProp) re-initializes — no stale copy.
const selectedProvider = provider;
const currentModel = model;
const filteredTools = activeModule ? AI_TOOLS.filter((t) => t.module === activeModule) : AI_TOOLS;
const modules = [...new Set(AI_TOOLS.map((t) => t.module))];
@@ -1846,19 +1858,27 @@ export default function AIsettingsClient({
currentProvider={selectedProvider}
currentModel={currentModel}
brandId={brandId}
onSelect={setSelectedProvider}
onSelect={() => {
// Local state is derived from `provider` prop. After a server
// refresh the new provider flows in via the parent re-render.
}}
/>
{/* Model Input */}
<ModelInput
key={currentModel}
currentModel={currentModel}
brandId={brandId}
onChange={setCurrentModel}
onChange={() => {
// Local state is derived from `model` prop. `key={currentModel}`
// remounts this component so its internal useState(currentModel)
// re-syncs to the new value.
}}
/>
{/* Module filter */}
<div className="admin-filter-tabs mb-6">
<button
<button type="button"
onClick={() => setActiveModule(null)}
className={`admin-filter-tab ${activeModule === null ? 'admin-filter-tab--active' : ''}`}
>
@@ -1867,7 +1887,7 @@ export default function AIsettingsClient({
{modules.map((m) => {
const count = AI_TOOLS.filter((t) => t.module === m).length;
return (
<button
<button type="button"
key={m}
onClick={() => setActiveModule(activeModule === m ? null : m)}
className={`admin-filter-tab ${activeModule === m ? 'admin-filter-tab--active' : ''}`}

Some files were not shown because too many files have changed in this diff Show More