feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+45 -33
View File
@@ -1,48 +1,60 @@
"use server";
import { auth } from "@/lib/auth";
import { query } from "@/lib/db";
import "server-only";
import { getSession } from "@/lib/auth";
import { setUserPassword } from "@/lib/auth";
import { getAdminUser } from "@/lib/admin-permissions";
const MIN_PASSWORD_LENGTH = 8;
/**
* Update the current user's Supabase auth password.
*
* Reads the Auth.js v5 session to identify the user. The session's
* `user.id` is either:
* - a Supabase auth user id (UUID) for email/password sign-ins
* - a Google `sub` (non-UUID) for Google sign-ins — these are not
* provisioned in Supabase auth, so the RPC will reject them. Google
* users must be provisioned in Supabase auth separately.
*
* The password update itself runs as a SECURITY DEFINER PL/pgSQL function
* (`update_user_password`) inside the database, called directly via the
* shared `pg` pool. No Supabase REST hop required.
* Change a user's password. Requires admin privileges.
*
* Use this for admin-initiated password resets. For self-service
* password changes, users should use the forgot password flow.
*/
export async function updatePasswordAction(
userId: string,
newPassword: string
): Promise<{ error?: string; userId?: string }> {
const session = await auth();
const uid = session?.user?.id;
if (!uid) {
return { error: "Not authenticated. Please log in again." };
): Promise<{ success: boolean; error?: string }> {
// Verify the caller is an admin
const adminUser = await getAdminUser();
if (!adminUser) {
return { success: false, error: "Not authenticated. Please log in again." };
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return {
error:
"Password change is not available for social sign-in accounts. Please contact an admin.",
};
if (adminUser.role !== "platform_admin") {
return { success: false, error: "Only platform admins can change passwords." };
}
// Validate password
if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) {
return { success: false, error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` };
}
try {
// The RPC is SECURITY DEFINER and returns a single row (or raises).
// We SELECT it (rather than SELECT update_user_password(...)) so the
// call stays a normal parameterized query and we can read the result.
await query("SELECT update_user_password($1, $2)", [uid, newPassword]);
return { userId: uid };
const result = await setUserPassword({
userId,
newPassword,
});
if (result.error) {
console.error("[updatePassword] Failed:", result.error);
return { success: false, error: result.error.message ?? "Failed to update password." };
}
console.log("[updatePassword] Password updated for user:", userId);
return { success: true };
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to update password.";
return { error: message };
console.error("[updatePassword] Unexpected error:", err);
return { success: false, error: "An unexpected error occurred." };
}
}
/**
* Get the current session user ID. Used by the change-password UI.
*/
export async function getCurrentUserId(): Promise<string | null> {
const { data: session } = await getSession();
return session?.user?.id ?? null;
}
+17 -81
View File
@@ -3,9 +3,6 @@
import "server-only";
import { cookies } from "next/headers";
import { pool, query } from "@/lib/db";
import { getMockTableData, mockBrands } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type AdminUserRow = {
id: string;
@@ -112,16 +109,30 @@ async function sendWelcomeEmailSafe(input: {
name: string;
role: "platform_admin" | "brand_admin" | "store_employee";
password: string;
brandId?: string;
}): Promise<void> {
try {
const { sendWelcomeEmail } = await import("@/lib/email-service");
const { getBrandSettings } = await import("@/actions/brand-settings");
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
let logoUrl: string | null = null;
let brandName = "Route Commerce";
if (input.brandId) {
const settings = await getBrandSettings(input.brandId);
if (settings.success && settings.settings) {
logoUrl = settings.settings.logo_url ?? null;
brandName = settings.settings.brand_name ?? brandName;
}
}
await sendWelcomeEmail({
to: input.to,
name: input.name,
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
brandName: "Tuxedo Corn",
brandName,
tempPassword: input.password,
logoUrl: logoUrl ?? undefined,
});
} catch {
// welcome email is best-effort; never block user creation
@@ -131,14 +142,6 @@ async function sendWelcomeEmailSafe(input: {
// ─── Public actions ─────────────────────────────────────────────────────────
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
return {
users: brandId ? mockUsers.filter((u) => u.brand_id === brandId) : mockUsers,
error: null,
};
}
try {
const sql = brandId
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
@@ -168,35 +171,6 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse
}
export async function createAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const newRow: AdminUserRow = {
id: `mock-${Date.now()}`,
user_id: null,
display_name: input.display_name ?? input.email.split("@")[0],
email: input.email,
phone_number: input.phone_number ?? null,
role: input.role,
brand_id: input.brand_id,
brand_name: null,
can_manage_products: input.flags.can_manage_products ?? false,
can_manage_stops: input.flags.can_manage_stops ?? false,
can_manage_orders: input.flags.can_manage_orders ?? false,
can_manage_pickup: input.flags.can_manage_pickup ?? false,
can_manage_messages: input.flags.can_manage_messages ?? false,
can_manage_refunds: input.flags.can_manage_refunds ?? false,
can_manage_users: input.flags.can_manage_users ?? false,
can_manage_water_log: input.flags.can_manage_water_log ?? false,
can_manage_reports: input.flags.can_manage_reports ?? false,
active: true,
must_change_password: input.mustChangePassword ?? true,
created_at: new Date().toISOString(),
last_login: null,
};
mockUsers.push(newRow);
return { user: newRow, error: null };
}
try {
// No Supabase Auth — `user_id` stays NULL until the user signs in
// via Auth.js and `get_admin_user_for_session` matches them by
@@ -242,6 +216,7 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
name: input.display_name ?? input.email.split("@")[0],
role: input.role,
password: input.password,
brandId: input.brand_id ?? undefined,
});
return { user: mapUserRow(rows[0]), error: null };
@@ -251,25 +226,6 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us
}
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === input.id);
if (idx === -1) return { user: null, error: "User not found" };
const merged: AdminUserRow = { ...mockUsers[idx] };
if (input.role !== undefined) merged.role = input.role;
if (input.brand_id !== undefined) merged.brand_id = input.brand_id;
if (input.active !== undefined) merged.active = input.active;
if (input.display_name !== undefined) merged.display_name = input.display_name;
if (input.phone_number !== undefined) merged.phone_number = input.phone_number;
if (input.flags) {
for (const [k, v] of Object.entries(input.flags)) {
if (v !== undefined) (merged as Record<string, unknown>)[k] = v;
}
}
mockUsers[idx] = merged;
return { user: merged, error: null };
}
try {
// Build a partial SET clause. Each `can_manage_*` column is set
// individually — the input's `flags` partial is spread across them.
@@ -306,14 +262,6 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us
}
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const idx = mockUsers.findIndex((u) => u.id === id);
if (idx === -1) return { success: false, error: "User not found" };
mockUsers.splice(idx, 1);
return { success: true, error: null };
}
try {
// No Supabase Auth — nothing to delete from the auth service.
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
@@ -324,14 +272,6 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e
}
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
if (useMockData) {
const mockUsers = getMockTableData("users") as AdminUserRow[];
const u = mockUsers.find((m) => m.id === userId);
if (!u) return { success: false, error: "User not found" };
u.must_change_password = true;
return { success: true, error: null };
}
try {
const { rowCount } = await query(
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
@@ -358,14 +298,10 @@ export async function sendPasswordResetEmail(_email: string): Promise<{ success:
}
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
if (useMockData) {
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
}
try {
// The SaaS rebuild renamed `brands` → `tenants` (see db/schema/tenants.ts).
// Sort by name so the platform admin's brand picker stays stable.
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM tenants ORDER BY name`,
`SELECT id, name FROM brands ORDER BY name`,
);
return { brands: rows, error: null };
} catch (err) {
+3 -3
View File
@@ -32,7 +32,7 @@ export async function saveAIPreferences(
brandId: string,
config: AIAuthConfig
): Promise<{ success: boolean; error?: string }> {
const { error } = await supabase
const result = await supabase
.from("brand_ai_settings")
.upsert({
brand_id: brandId,
@@ -43,9 +43,9 @@ export async function saveAIPreferences(
model: config.model || "gpt-4o-mini",
max_tokens: config.max_tokens || 4000,
updated_at: new Date().toISOString(),
});
}) as { data: unknown; error: { message: string } | null };
if (error) return { success: false, error: error.message };
if (result.error) return { success: false, error: result.error.message };
return { success: true };
}
+8 -8
View File
@@ -64,7 +64,7 @@ export type ConversionFunnel = {
// when the relevant data isn't present.
//
// `recent_orders` / `conversion_funnel` use the new `orders` schema directly
// (total_cents, placed_at, fulfillment, status, customer_id, tenant_id).
// (total_cents, placed_at, fulfillment, status, customer_id, brand_id).
/**
* Aggregate KPIs over a date window. Replaces the
@@ -84,7 +84,7 @@ async function getReportsSummary(
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = `AND tenant_id = $3::uuid`;
brandFilter = `AND brand_id = $3::uuid`;
params.push(brandId);
}
const { rows } = await pool.query<{
@@ -137,7 +137,7 @@ export async function getAnalyticsMetrics(periodDays: number = 30): Promise<Anal
const customerParams: unknown[] = [];
let customerFilter = "";
if (brandId) {
customerFilter = "WHERE tenant_id = $1::uuid";
customerFilter = "WHERE brand_id = $1::uuid";
customerParams.push(brandId);
}
const customerCountRes = await pool.query<{ count: string }>(
@@ -184,7 +184,7 @@ export async function getRevenueChart(periodDays: number = 30): Promise<RevenueD
const params: unknown[] = [startDate.toISOString().split("T")[0], endDate.toISOString().split("T")[0]];
let brandFilter = "";
if (brandId) {
brandFilter = "AND tenant_id = $3::uuid";
brandFilter = "AND brand_id = $3::uuid";
params.push(brandId);
}
const { rows } = await pool.query<{ date: string; revenue: number; orders: number }>(
@@ -219,7 +219,7 @@ export async function getTopProducts(limit: number = 5): Promise<ProductPerforma
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = "AND o.tenant_id = $3::uuid";
brandFilter = "AND o.brand_id = $3::uuid";
params.push(brandId);
}
@@ -270,7 +270,7 @@ export async function getRecentOrders(limit: number = 10): Promise<RecentOrder[]
const params: unknown[] = [];
let brandFilter = "";
if (brandId) {
brandFilter = "WHERE tenant_id = $1::uuid";
brandFilter = "WHERE brand_id = $1::uuid";
params.push(brandId);
}
const cappedLimit = Math.max(1, Math.min(limit, 100));
@@ -322,7 +322,7 @@ export async function getCustomerGrowth(): Promise<CustomerGrowth> {
const params: unknown[] = [startDate, endDate];
let brandFilter = "";
if (brandId) {
brandFilter = "AND tenant_id = $3::uuid";
brandFilter = "AND brand_id = $3::uuid";
params.push(brandId);
}
@@ -365,7 +365,7 @@ export async function getConversionFunnel(): Promise<ConversionFunnel[]> {
const params: unknown[] = [];
let brandFilter = "";
if (brandId) {
brandFilter = "WHERE tenant_id = $1::uuid";
brandFilter = "WHERE brand_id = $1::uuid";
params.push(brandId);
}
const { rows } = await pool.query<{ status: string }>(
+5 -35
View File
@@ -1,44 +1,14 @@
"use server";
import "server-only";
import { signIn, signOut } from "@/lib/auth";
import { signOut } from "@/lib/auth";
import { redirect } from "next/navigation";
/**
* Kick off the Google OAuth flow. Auth.js will redirect to Google's
* consent screen and then back to /api/auth/callback/google, which sets
* the session cookie and redirects to /admin.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
/**
* Sign in with email + password. The `credentials` provider is enabled
* in dev (see `isDevLoginEnabled()` in `src/auth.config.ts`); in
* production it is omitted entirely and this action returns an
* `AuthError` (Auth.js surfaces `?error=Configuration` on /login).
*
* On a failed credential check Auth.js redirects back to
* /login?error=CredentialsSignin, which the LoginClient renders as
* "Invalid email or password."
*/
export async function signInWithCredentials(formData: FormData): Promise<void> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
redirect("/login?error=MissingCredentials");
}
await signIn("credentials", {
email,
password,
redirectTo: "/admin",
});
}
/**
* Sign out and clear the Auth.js session cookie.
* Sign out and clear the Neon Auth session cookie.
*/
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
console.log("[auth/sign-out] Signing out");
await signOut();
redirect("/login");
}
-32
View File
@@ -1,32 +0,0 @@
"use server";
import { signIn, signOut } from "@/lib/auth";
/**
* Server actions that wrap the Auth.js v5 `signIn` / `signOut` API for
* use from client components.
*
* Why server actions?
* • The Auth.js v5 `signIn` function has to run on the server (it
* needs to set the session cookie, talk to the database adapter,
* and redirect the user to the OAuth provider).
* • Calling it from a client component via a server action keeps the
* client bundle small and avoids exposing the OAuth client secret.
*
* Usage from a client component:
* <form action={signInWithGoogle}>
* <button type="submit">Sign in with Google</button>
* </form>
*
* Note: dev/demo authentication is no longer a button on the login page.
* `src/middleware.ts` auto-issues the `dev_session` cookie for /admin/*
* when ALLOW_DEV_LOGIN is enabled. See CLAUDE.md.
*/
export async function signInWithGoogle(): Promise<void> {
await signIn("google", { redirectTo: "/admin" });
}
export async function signOutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}
+9 -9
View File
@@ -37,7 +37,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { withTenant } from "@/db/client";
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";
@@ -113,14 +113,14 @@ export async function getBillingOverview(
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
WHERE s.brand_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
FROM brands t
WHERE t.id = $1`,
[brandId]
);
@@ -136,14 +136,14 @@ export async function getBillingOverview(
}>(
`SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id,
NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end
FROM tenants WHERE id = $1`,
FROM brands WHERE id = $1`,
[brandId]
);
const brand = brandRes.rows[0] ?? null;
// 3) Enabled add-ons (feature flags from brand_settings)
const addonsRes = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
`SELECT feature_flags FROM brand_settings WHERE tenant_id = $1`,
`SELECT feature_flags FROM brand_settings WHERE brand_id = $1`,
[brandId]
);
const flags = addonsRes.rows[0]?.feature_flags ?? {};
@@ -155,13 +155,13 @@ export async function getBillingOverview(
// 4) Active product count — same semantics as the dashboard
// (active=true). Keeps the billing page in sync with /admin
// "Active Products" stat.
const productCountRows = await withTenant(brandId, (db) =>
const productCountRows = await withBrand(brandId, (db) =>
db
.select({ value: count() })
.from(products)
.where(
and(
eq(products.tenantId, brandId),
eq(products.brandId, brandId),
eq(products.active, true)
)
)
+5 -2
View File
@@ -9,6 +9,9 @@ type LineItem = {
quantity: number;
};
// Stripe API version type - using const assertion for type safety
type StripeApiVersion = "2026-05-27.dahlia";
export async function createRetailStripeCheckoutSession(
items: LineItem[],
orderId: string,
@@ -20,7 +23,7 @@ export async function createRetailStripeCheckoutSession(
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-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const lineItems = items.map((item) => ({
price_data: {
@@ -35,7 +38,7 @@ export async function createRetailStripeCheckoutSession(
let brandName = "Route Commerce";
try {
const brandRes = await pool.query<{ name: string }>(
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
[brandId]
);
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
+5 -2
View File
@@ -2,6 +2,9 @@
import { pool } from "@/lib/db";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
/**
* Creates a Stripe PaymentIntent for the supplied cart so the browser
* can confirm the payment with embedded Stripe Elements (Apple Pay /
@@ -46,7 +49,7 @@ export async function createRetailPaymentIntent(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.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
@@ -67,7 +70,7 @@ export async function createRetailPaymentIntent(
if (brandId) {
try {
const brandRes = await pool.query<{ name: string }>(
"SELECT name FROM tenants WHERE id = $1 LIMIT 1",
"SELECT name FROM brands WHERE id = $1 LIMIT 1",
[brandId]
);
if (brandRes.rows[0]?.name) brandName = brandRes.rows[0].name;
+7 -4
View File
@@ -3,6 +3,9 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
// Stripe API version type - using const assertion for type safety
type StripeApiVersion = "2026-05-27.dahlia";
// ── Price ID config ────────────────────────────────────────────────────────────
// Maps plan/addon keys to Stripe price IDs via environment variables
@@ -45,7 +48,7 @@ export async function createStripeCheckoutSession(
// Get brand's Stripe customer ID
const custRes = await pool.query<{ stripe_customer_id: string | null; name: string }>(
"SELECT stripe_customer_id, name FROM tenants WHERE id = $1 LIMIT 1",
"SELECT stripe_customer_id, name FROM brands WHERE id = $1 LIMIT 1",
[brandId]
);
const brand = custRes.rows[0];
@@ -54,7 +57,7 @@ export async function createStripeCheckoutSession(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
@@ -121,7 +124,7 @@ export async function cancelAddonSubscription(
// Get active subscription for this brand
const subRes = await pool.query<{ stripe_subscription_id: string | null }>(
"SELECT stripe_subscription_id FROM subscriptions WHERE tenant_id = $1 LIMIT 1",
"SELECT stripe_subscription_id FROM subscriptions WHERE brand_id = $1 LIMIT 1",
[brandId]
);
if (subRes.rows.length === 0) {
@@ -133,7 +136,7 @@ export async function cancelAddonSubscription(
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
// Retrieve subscription and find the item for this add-on
const subscription = await stripe.subscriptions.retrieve(subData.stripe_subscription_id);
+32 -9
View File
@@ -3,6 +3,29 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
// Type for plan info response
type PlanInfo = {
plan_tier: string;
plan_name: string | null;
max_users: number;
max_stops_monthly: number;
max_products: number;
usage: { users: number; stops_this_month: number; products: number } | null;
};
// Type for wholesale order
type WholesaleOrder = {
id: string;
created_at: Date;
customer_name: string;
total: number;
status: string;
[key: string]: unknown;
};
export async function getStripeBillingPortalUrl(brandId: string): Promise<{ success: boolean; url?: string; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
@@ -15,7 +38,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
// Get stripe_customer_id from tenants table
const custRes = await pool.query<{ stripe_customer_id: string | null }>(
"SELECT stripe_customer_id FROM tenants WHERE id = $1 LIMIT 1",
"SELECT stripe_customer_id FROM brands WHERE id = $1 LIMIT 1",
[brandId]
);
const stripeCustomerId = custRes.rows[0]?.stripe_customer_id;
@@ -25,7 +48,7 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ
}
const Stripe = (await import("stripe")).default;
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
@@ -74,7 +97,7 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome
}
}
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> {
export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: PlanInfo; error?: string }> {
// Replicate get_brand_plan_info via a JOIN on tenants + plans
const res = await pool.query<{
plan_tier: string;
@@ -91,14 +114,14 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool
COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly,
COALESCE(t.max_products, 25) AS max_products,
jsonb_build_object(
'users', (SELECT count(*)::int FROM tenant_users tu WHERE tu.tenant_id = t.id),
'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id),
'stops_this_month', (SELECT count(*)::int FROM stops s
WHERE s.tenant_id = t.id
WHERE s.brand_id = t.id
AND s.created_at >= date_trunc('month', now())),
'products', (SELECT count(*)::int FROM products p
WHERE p.tenant_id = t.id AND p.active = true AND p.deleted_at IS NULL)
WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL)
) AS usage
FROM tenants t
FROM brands t
WHERE t.id = $1`,
[brandId]
);
@@ -111,7 +134,7 @@ 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
const res = await pool.query<{ feature_flags: Record<string, unknown> | null }>(
"SELECT feature_flags FROM brand_settings WHERE tenant_id = $1 LIMIT 1",
"SELECT feature_flags FROM brand_settings WHERE brand_id = $1 LIMIT 1",
[brandId]
);
const flags = res.rows[0]?.feature_flags ?? {};
@@ -123,7 +146,7 @@ export async function getEnabledAddons(brandId: string): Promise<Record<string,
return out;
}
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<any[]> {
export async function getRecentWholesaleOrders(brandId: string, limit = 20): Promise<WholesaleOrder[]> {
try {
const res = await pool.query(
"SELECT * FROM get_wholesale_orders($1)",
+54 -86
View File
@@ -2,21 +2,16 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { uploadObject, BUCKETS } from "@/lib/storage";
export type UploadLogoResult =
| { success: true; logoUrl: string }
| { success: false; error: string };
/**
* Upload a brand logo (light or dark variant) to Supabase Storage and
* save the resulting public URL to the brand_settings row via the
* Upload a brand logo (light or dark variant) to MinIO and save the
* resulting public URL to the brand_settings row via the
* `upsert_brand_settings` SECURITY DEFINER RPC.
*
* NOTE: the storage PUT itself is not a database call, so it still
* goes through the Supabase Storage REST API. Storage migration to an
* S3-compatible backend is tracked separately; until that lands, the
* public URL pattern (`/storage/v1/object/public/...`) keeps working
* for any pre-existing bucket.
*/
export async function uploadBrandLogo(
brandId: string,
@@ -42,37 +37,28 @@ export async function uploadBrandLogo(
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const storagePath = `brand-logos/${brandId}/${path}`;
const storageKey = `brand-logos/${brandId}/${path}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
[isDark ? "p_logo_url_dark" : "p_logo_url"]: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveOk = await callUpsertBrandSettings(brandId, {
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
}
export async function uploadOlatheSweetLogo(
@@ -97,37 +83,28 @@ export async function uploadOlatheSweetLogo(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url: publicUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
}
export async function uploadOlatheSweetLogoDark(
@@ -152,37 +129,28 @@ export async function uploadOlatheSweetLogoDark(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
const storageKey = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { apikey: supabaseKey, "Content-Type": file.type, "x-upsert": "true" },
try {
const buffer = Buffer.from(await file.arrayBuffer());
const logoUrl = await uploadObject({
bucket: BUCKETS.BRAND_LOGOS,
key: storageKey,
body: buffer,
contentType: file.type,
});
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url_dark: logoUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
return { success: true, logoUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const saveOk = await callUpsertBrandSettings(brandId, {
p_olathe_sweet_logo_url_dark: publicUrl,
});
if (!saveOk) {
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
}
/**
+17
View File
@@ -110,8 +110,24 @@ export async function createOrder(
// Send order receipt email
try {
const { sendOrderReceiptEmail } = await import("@/lib/email-service");
const { getBrandSettingsPublic } = await import("@/actions/brand-settings");
const rawItems = data.items ?? [];
const taxAmount = (data as { tax_amount?: number }).tax_amount ?? 0;
// Look up brand settings to get the logo URL
let logoUrl: string | null = null;
if (brandId) {
// Resolve brand slug from brand ID, then fetch settings
const { rows: brandRows } = await pool.query<{ slug: string }>(
"SELECT slug FROM brands WHERE id = $1",
[brandId]
);
if (brandRows[0]) {
const settings = await getBrandSettingsPublic(brandRows[0].slug);
logoUrl = settings.success ? (settings.settings?.logo_url ?? null) : null;
}
}
await sendOrderReceiptEmail({
customerName,
customerEmail,
@@ -131,6 +147,7 @@ export async function createOrder(
stopTime: data.stop_time ?? undefined,
stopLocation: data.stop_location ?? undefined,
brandName: "Tuxedo Corn",
logoUrl,
});
} catch {
// Email failure should not fail the order
+13 -13
View File
@@ -3,7 +3,7 @@
import { and, desc, eq, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns, emailTemplates } from "@/db/schema";
export type CampaignType = "marketing" | "operational" | "transactional";
@@ -83,7 +83,7 @@ function stripHtml(html: string | null): string {
function rowToCampaign(c: CampaignRow, t?: TemplateRow | null): Campaign {
return {
id: c.id,
brand_id: c.tenantId,
brand_id: c.brandId,
name: c.name,
subject: t?.subject ?? null,
body_text: stripHtml(t?.bodyHtml ?? null),
@@ -111,7 +111,7 @@ export async function getCommunicationCampaigns(brandId?: string): Promise<ListC
try {
const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
? await withBrand(activeBrandId, (db) =>
db
.select({
campaign: campaigns,
@@ -178,7 +178,7 @@ export async function upsertCampaign(params: {
const subject = params.subject ?? "";
const bodyHtml = params.body_html ?? (params.body_text ? `<p style="white-space:pre-wrap">${escapeHtml(params.body_text)}</p>` : "<p></p>");
const { row, template } = await withTenant(params.brand_id, async (db) => {
const { row, template } = await withBrand(params.brand_id, async (db) => {
// Resolve / create the linked email_templates row.
let templateId: string | null = params.template_id ?? null;
let templateRow: TemplateRow | null = null;
@@ -187,7 +187,7 @@ export async function upsertCampaign(params: {
const existing = await db
.select()
.from(emailTemplates)
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.tenantId, params.brand_id)))
.where(and(eq(emailTemplates.id, templateId), eq(emailTemplates.brandId, params.brand_id)))
.limit(1);
if (existing[0]) {
const updated = await db
@@ -207,7 +207,7 @@ export async function upsertCampaign(params: {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
brandId: params.brand_id,
name: params.name,
subject,
bodyHtml,
@@ -229,7 +229,7 @@ export async function upsertCampaign(params: {
scheduledFor: scheduled,
updatedAt: new Date(),
})
.where(and(eq(campaigns.id, params.id), eq(campaigns.tenantId, params.brand_id)))
.where(and(eq(campaigns.id, params.id), eq(campaigns.brandId, params.brand_id)))
.returning();
if (!updated[0]) {
return { row: null, template: null };
@@ -239,7 +239,7 @@ export async function upsertCampaign(params: {
const inserted = await db
.insert(campaigns)
.values({
tenantId: params.brand_id,
brandId: params.brand_id,
name: params.name,
templateId,
status,
@@ -274,8 +274,8 @@ export async function deleteCampaign(campaignId: string, brandId?: string): Prom
try {
if (activeBrandId) {
await withTenant(activeBrandId, (db) =>
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId))),
await withBrand(activeBrandId, (db) =>
db.delete(campaigns).where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId))),
);
} else {
await withPlatformAdmin((db) => db.delete(campaigns).where(eq(campaigns.id, campaignId)));
@@ -297,12 +297,12 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
try {
const result = activeBrandId
? await withTenant(activeBrandId, (db) =>
? await withBrand(activeBrandId, (db) =>
db
.select({ campaign: campaigns, template: emailTemplates })
.from(campaigns)
.leftJoin(emailTemplates, eq(emailTemplates.id, campaigns.templateId))
.where(and(eq(campaigns.id, campaignId), eq(campaigns.tenantId, activeBrandId)))
.where(and(eq(campaigns.id, campaignId), eq(campaigns.brandId, activeBrandId)))
.limit(1)
.then((r) => r[0] ?? null),
)
@@ -318,7 +318,7 @@ export async function getCampaignById(campaignId: string, brandId?: string): Pro
if (!result) return null;
if (adminUser.role === "brand_admin" && result.campaign.tenantId !== adminUser.brand_id) {
if (adminUser.role === "brand_admin" && result.campaign.brandId !== adminUser.brand_id) {
return null;
}
+37 -45
View File
@@ -4,7 +4,7 @@ import { and, eq, ilike, or, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { parseCSVWithLimits } from "@/lib/csv-parser";
import { buildImportPreview } from "@/lib/column-detector";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { customers } from "@/db/schema";
export type ContactSource = "order" | "import" | "manual" | "admin";
@@ -19,7 +19,7 @@ export type ContactSource = "order" | "import" | "manual" | "admin";
*/
export type Contact = {
id: string;
tenant_id: string;
brand_id: string;
email: string | null;
phone: string | null;
full_name: string;
@@ -101,23 +101,9 @@ export type GetContactsResult = {
};
function rowToContact(row: typeof customers.$inferSelect): Contact {
// Derive first/last name from the stored single `name` column.
// Multi-word names split on the first whitespace; single-word names
// populate first_name and leave last_name null.
const fullName = row.name ?? "";
const trimmed = fullName.trim();
let firstName: string | null = null;
let lastName: string | null = null;
if (trimmed) {
const idx = trimmed.indexOf(" ");
if (idx === -1) {
firstName = trimmed;
} else {
firstName = trimmed.slice(0, idx);
const rest = trimmed.slice(idx + 1).trim();
lastName = rest.length > 0 ? rest : null;
}
}
const firstName = row.firstName;
const lastName = row.lastName;
const fullName = [firstName, lastName].filter(Boolean).join(" ") || "";
// Derive unsubscribed_at: the new schema only has opt-in flags, not
// a timestamp. Mark the unsubscribe moment as updatedAt if opted out
@@ -126,10 +112,10 @@ function rowToContact(row: typeof customers.$inferSelect): Contact {
return {
id: row.id,
tenant_id: row.tenantId,
brand_id: row.brandId,
email: row.email,
phone: row.phone,
full_name: row.name,
full_name: row.fullName ?? "",
first_name: firstName,
last_name: lastName,
source: "manual",
@@ -162,13 +148,13 @@ export async function getContacts(params: {
const search = params.search?.trim() ?? "";
try {
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
if (search) {
const like = `%${search}%`;
conds.push(or(ilike(customers.name, like), ilike(customers.email, like))!);
conds.push(or(ilike(customers.firstName, like), ilike(customers.email, like))!);
}
const rows = await withTenant(params.brandId, async (db) => {
const rows = await withBrand(params.brandId, async (db) => {
const [items, countRows] = await Promise.all([
db
.select()
@@ -233,7 +219,7 @@ export async function upsertContact(contact: {
return { success: false, error: "Not authorized for this brand" };
}
const name =
const fullName =
contact.full_name?.trim() ||
[contact.first_name, contact.last_name].filter(Boolean).join(" ").trim() ||
contact.email ||
@@ -243,18 +229,20 @@ export async function upsertContact(contact: {
try {
if (contact.id) {
const contactId = contact.id;
const updated = await withTenant(contact.brand_id, (db) =>
const updated = await withBrand(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
fullName,
email: contact.email ?? null,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
updatedAt: new Date(),
})
.where(and(eq(customers.id, contactId), eq(customers.tenantId, contact.brand_id)))
.where(and(eq(customers.id, contactId), eq(customers.brandId, contact.brand_id)))
.returning(),
);
const row = updated[0];
@@ -262,23 +250,25 @@ export async function upsertContact(contact: {
return { success: true, contact: rowToContact(row) };
}
// INSERT — de-dupe on (tenant_id, email) when email is provided
// INSERT — de-dupe on (brand_id, email) when email is provided
const contactEmail = contact.email;
if (contactEmail) {
const existing = await withTenant(contact.brand_id, (db) =>
const existing = await withBrand(contact.brand_id, (db) =>
db
.select()
.from(customers)
.where(and(eq(customers.email, contactEmail), eq(customers.tenantId, contact.brand_id)))
.where(and(eq(customers.email, contactEmail), eq(customers.brandId, contact.brand_id)))
.limit(1),
);
if (existing[0]) {
const updated = await withTenant(contact.brand_id, (db) =>
const updated = await withBrand(contact.brand_id, (db) =>
db
.update(customers)
.set({
name,
fullName,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? existing[0].smsOptIn,
emailOptIn: contact.email_opt_in ?? existing[0].emailOptIn,
updatedAt: new Date(),
@@ -292,14 +282,16 @@ export async function upsertContact(contact: {
}
}
const inserted = await withTenant(contact.brand_id, (db) =>
const inserted = await withBrand(contact.brand_id, (db) =>
db
.insert(customers)
.values({
tenantId: contact.brand_id,
name,
brandId: contact.brand_id,
fullName,
email: contact.email ?? null,
phone: contact.phone ?? null,
firstName: contact.first_name ?? null,
lastName: contact.last_name ?? null,
smsOptIn: contact.sms_opt_in ?? false,
emailOptIn: contact.email_opt_in ?? true,
})
@@ -328,7 +320,7 @@ export type ImportContactsResult = {
* The legacy `import_communication_contacts_batch` RPC is replaced with
* an in-process batch: parse → dedupe → upsert per row, returning the
* same ImportResult shape. This avoids a round-trip to the DB for each
* row and keeps the call inside the `withTenant` transaction.
* row and keeps the call inside the `withBrand` transaction.
*/
export async function importContactsBatch(params: {
brandId: string;
@@ -403,14 +395,14 @@ export async function optOutContact(params: {
}
try {
await withTenant(params.brandId, (db) =>
await withBrand(params.brandId, (db) =>
db
.update(customers)
.set({
...(params.method === "email" ? { emailOptIn: false } : { smsOptIn: false }),
updatedAt: new Date(),
})
.where(and(eq(customers.email, params.email), eq(customers.tenantId, params.brandId))),
.where(and(eq(customers.email, params.email), eq(customers.brandId, params.brandId))),
);
return { success: true };
} catch (err) {
@@ -434,10 +426,10 @@ export async function deleteContact(id: string, brandId?: string): Promise<{ suc
try {
if (brandId) {
await withTenant(brandId, (db) =>
await withBrand(brandId, (db) =>
db
.delete(customers)
.where(and(eq(customers.id, id), eq(customers.tenantId, brandId))),
.where(and(eq(customers.id, id), eq(customers.brandId, brandId))),
);
} else {
// platform_admin fallback — by id only
@@ -492,20 +484,20 @@ export async function exportContacts(params: {
try {
while (true) {
const rows = await withTenant(effectiveBrandId, (db) =>
const rows = await withBrand(effectiveBrandId, (db) =>
db
.select()
.from(customers)
.where(
params.search
? and(
eq(customers.tenantId, effectiveBrandId),
eq(customers.brandId, effectiveBrandId),
or(
ilike(customers.name, `%${params.search}%`),
ilike(customers.firstName, `%${params.search}%`),
ilike(customers.email, `%${params.search}%`),
),
)
: eq(customers.tenantId, effectiveBrandId),
: eq(customers.brandId, effectiveBrandId),
)
.limit(batchSize)
.offset(offset)
@@ -2,7 +2,7 @@
import { and, desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { files } from "@/db/schema";
import {
importContactsBatch,
@@ -45,11 +45,11 @@ export async function uploadContactsToBucket(
const storageKey = `imports/${brandId}/${timestamp}-${safeName}`;
try {
const [row] = await withTenant(brandId, (db) =>
const [row] = await withBrand(brandId, (db) =>
db
.insert(files)
.values({
tenantId: brandId,
brandId: brandId,
storageKey,
mimeType: "text/csv",
sizeBytes: file.size,
@@ -130,7 +130,7 @@ export async function listImportHistory(
if (!adminUser) return { success: false, error: "Not authenticated" };
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({
storageKey: files.storageKey,
@@ -138,7 +138,7 @@ export async function listImportHistory(
createdAt: files.createdAt,
})
.from(files)
.where(and(eq(files.tenantId, brandId), eq(files.purpose, "contact_import")))
.where(and(eq(files.brandId, brandId), eq(files.purpose, "contact_import")))
.orderBy(desc(files.createdAt))
.limit(limit),
);
+6 -6
View File
@@ -2,7 +2,7 @@
import { eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import type { AudienceRules } from "./campaigns";
@@ -56,22 +56,22 @@ function isSegment(v: unknown): v is Segment {
}
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1),
);
return readSegments((rows[0]?.flags ?? null) as Record<string, unknown> | null);
}
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
await withTenant(brandId, async (db) => {
await withBrand(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
@@ -81,7 +81,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
await db
.update(brandSettings)
.set({ featureFlags: nextFlags, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
.where(eq(brandSettings.brandId, brandId));
});
}
+10 -10
View File
@@ -3,13 +3,13 @@
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns, customers } from "@/db/schema";
import type { AudienceRules } from "./campaigns";
export type AudiencePreviewResult = {
count: number;
sample_customers: { id: string; email: string; name: string }[];
sample_customers: { id: string; email: string; fullName: string | null }[];
};
/**
@@ -36,23 +36,23 @@ export async function previewCampaignAudience(
}
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({
id: customers.id,
email: customers.email,
name: customers.name,
fullName: customers.fullName,
})
.from(customers)
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)))
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true)))
.limit(5),
);
const countRows = await withTenant(brandId, (db) =>
const countRows = await withBrand(brandId, (db) =>
db
.select({ value: sql<number>`count(*)::int` })
.from(customers)
.where(and(eq(customers.tenantId, brandId), eq(customers.emailOptIn, true))),
.where(and(eq(customers.brandId, brandId), eq(customers.emailOptIn, true))),
);
return {
@@ -60,7 +60,7 @@ export async function previewCampaignAudience(
sample_customers: rows.map((r) => ({
id: r.id,
email: r.email ?? "",
name: r.name,
fullName: r.fullName,
})),
};
} catch {
@@ -153,11 +153,11 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis
}
const conds: SQL[] = [eq(campaigns.id, campaignId)];
if (activeBrandId) conds.push(eq(campaigns.tenantId, activeBrandId));
if (activeBrandId) conds.push(eq(campaigns.brandId, activeBrandId));
try {
const updated = activeBrandId
? await withTenant(activeBrandId, (db) =>
? await withBrand(activeBrandId, (db) =>
db
.update(campaigns)
.set({ status: "sent", sentAt: new Date() })
+12 -12
View File
@@ -1,7 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
@@ -47,15 +47,15 @@ function readFlag(
export async function getCommunicationSettings(brandId: string): Promise<CommunicationSettings | null> {
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({
tenantId: brandSettings.tenantId,
brandId: brandSettings.brandId,
featureFlags: brandSettings.featureFlags,
updatedAt: brandSettings.updatedAt,
})
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1),
);
@@ -65,8 +65,8 @@ export async function getCommunicationSettings(brandId: string): Promise<Communi
const flags = (row.featureFlags ?? {}) as Record<string, unknown>;
return {
id: row.tenantId,
brand_id: row.tenantId,
id: row.brandId,
brand_id: row.brandId,
default_sender_email: readFlag(flags, "comm_default_sender_email"),
default_sender_name: readFlag(flags, "comm_default_sender_name"),
reply_to_email: readFlag(flags, "comm_reply_to_email"),
@@ -96,11 +96,11 @@ export async function upsertCommunicationSettings(params: {
}
try {
const updated = await withTenant(params.brand_id, async (db) => {
const updated = await withBrand(params.brand_id, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, params.brand_id))
.where(eq(brandSettings.brandId, params.brand_id))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
@@ -115,9 +115,9 @@ export async function upsertCommunicationSettings(params: {
const result = await db
.update(brandSettings)
.set({ featureFlags: nextFlags, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, params.brand_id))
.where(eq(brandSettings.brandId, params.brand_id))
.returning({
tenantId: brandSettings.tenantId,
brandId: brandSettings.brandId,
updatedAt: brandSettings.updatedAt,
});
return result[0] ?? null;
@@ -128,8 +128,8 @@ export async function upsertCommunicationSettings(params: {
}
const settings: CommunicationSettings = {
id: updated.tenantId,
brand_id: updated.tenantId,
id: updated.brandId,
brand_id: updated.brandId,
default_sender_email: params.sender_email ?? null,
default_sender_name: params.sender_name ?? null,
reply_to_email: params.reply_to_email ?? null,
+7 -7
View File
@@ -2,7 +2,7 @@
import { and, eq, sql, SQL } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { customers, orders, campaigns, emailTemplates } from "@/db/schema";
export type StopBlastResult =
@@ -37,8 +37,8 @@ export async function sendStopBlast(params: {
}
try {
const conds: SQL[] = [eq(customers.tenantId, params.brandId)];
const recipientRows = await withTenant(params.brandId, async (db) => {
const conds: SQL[] = [eq(customers.brandId, params.brandId)];
const recipientRows = await withBrand(params.brandId, async (db) => {
// Distinct customers from the tenant's recent orders.
const orderCustomers = await db
.selectDistinct({ id: customers.id })
@@ -46,7 +46,7 @@ export async function sendStopBlast(params: {
.innerJoin(orders, eq(orders.customerId, customers.id))
.where(
and(
eq(orders.tenantId, params.brandId),
eq(orders.brandId, params.brandId),
params.channel === "sms"
? eq(customers.smsOptIn, true)
: params.channel === "email"
@@ -81,11 +81,11 @@ export async function sendStopBlast(params: {
// Persist a draft campaign for traceability. No template link — the
// stop-blast content is supplied inline and not yet modeled.
const bodyHtml = `<p style="white-space:pre-wrap">${escapeHtml(params.body)}</p>`;
const inserted = await withTenant(params.brandId, async (db) => {
const inserted = await withBrand(params.brandId, async (db) => {
const template = await db
.insert(emailTemplates)
.values({
tenantId: params.brandId,
brandId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
subject: params.subject ?? "Pickup update",
bodyHtml,
@@ -96,7 +96,7 @@ export async function sendStopBlast(params: {
const campaign = await db
.insert(campaigns)
.values({
tenantId: params.brandId,
brandId: params.brandId,
name: `Stop blast ${new Date().toISOString()}`,
templateId: tplId,
status: "sent",
+6 -6
View File
@@ -2,7 +2,7 @@
import { and, desc, eq, isNotNull } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { customers, orders, campaigns } from "@/db/schema";
/**
@@ -57,13 +57,13 @@ export async function getStopMessagingData(params: {
}
try {
const [orderRows, campaignRows] = await withTenant(brandId, async (db) => {
const [orderRows, campaignRows] = await withBrand(brandId, async (db) => {
const o = await db
.select({
id: orders.id,
status: orders.status,
placedAt: orders.placedAt,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
customerPhone: customers.phone,
})
@@ -71,7 +71,7 @@ export async function getStopMessagingData(params: {
.innerJoin(customers, eq(customers.id, orders.customerId))
.where(
and(
eq(orders.tenantId, brandId),
eq(orders.brandId, brandId),
isNotNull(customers.email),
),
)
@@ -86,7 +86,7 @@ export async function getStopMessagingData(params: {
updatedAt: campaigns.updatedAt,
})
.from(campaigns)
.where(eq(campaigns.tenantId, brandId))
.where(eq(campaigns.brandId, brandId))
.orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt))
.limit(10);
@@ -97,7 +97,7 @@ export async function getStopMessagingData(params: {
// schema; treat "fulfilled" status as picked up).
const mappedOrders: StopOrder[] = orderRows.map((o) => ({
id: o.id,
customer_name: o.customerName,
customer_name: o.customerName ?? "",
customer_email: o.customerEmail,
customer_phone: o.customerPhone,
pickup_complete: o.status === "fulfilled",
+11 -11
View File
@@ -3,7 +3,7 @@
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { emailTemplates } from "@/db/schema";
export type TemplateType = "email_template" | "sms_template" | "internal_note";
@@ -20,7 +20,7 @@ export type CampaignType = "marketing" | "operational" | "transactional";
*/
export type Template = {
id: string;
tenant_id: string;
brand_id: string;
name: string;
subject: string;
body_text: string;
@@ -42,7 +42,7 @@ export type UpsertTemplateResult = {
function rowToTemplate(row: typeof emailTemplates.$inferSelect): Template {
return {
id: row.id,
tenant_id: row.tenantId,
brand_id: row.brandId,
name: row.name,
subject: row.subject,
body_text: stripHtml(row.bodyHtml),
@@ -79,7 +79,7 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc
try {
const rows = activeBrandId
? await withTenant(activeBrandId, (db) =>
? await withBrand(activeBrandId, (db) =>
db
.select()
.from(emailTemplates)
@@ -121,7 +121,7 @@ export async function upsertTemplate(params: {
try {
const row = params.id
? await withTenant(params.brand_id, async (db) => {
? await withBrand(params.brand_id, async (db) => {
const updated = await db
.update(emailTemplates)
.set({
@@ -133,17 +133,17 @@ export async function upsertTemplate(params: {
.where(
and(
eq(emailTemplates.id, params.id!),
eq(emailTemplates.tenantId, params.brand_id),
eq(emailTemplates.brandId, params.brand_id),
),
)
.returning();
return updated[0] ?? null;
})
: await withTenant(params.brand_id, async (db) => {
: await withBrand(params.brand_id, async (db) => {
const inserted = await db
.insert(emailTemplates)
.values({
tenantId: params.brand_id,
brandId: params.brand_id,
name: params.name,
subject: params.subject,
bodyHtml,
@@ -170,14 +170,14 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
try {
const row = activeBrandId
? await withTenant(activeBrandId, (db) =>
? await withBrand(activeBrandId, (db) =>
db
.select()
.from(emailTemplates)
.where(
and(
eq(emailTemplates.id, templateId),
eq(emailTemplates.tenantId, activeBrandId),
eq(emailTemplates.brandId, activeBrandId),
),
)
.limit(1)
@@ -194,7 +194,7 @@ export async function getTemplateById(templateId: string): Promise<Template | nu
if (!row) return null;
if (adminUser.role === "brand_admin" && row.tenantId !== adminUser.brand_id) {
if (adminUser.role === "brand_admin" && row.brandId !== adminUser.brand_id) {
return null;
}
+5 -5
View File
@@ -2,7 +2,7 @@
import { desc, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant, withPlatformAdmin } from "@/db/client";
import { withBrand, withPlatformAdmin } from "@/db/client";
import { campaigns } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns";
@@ -57,7 +57,7 @@ export type CampaignAnalytics = {
function rowToCampaign(row: typeof campaigns.$inferSelect): Campaign {
return {
id: row.id,
brand_id: row.tenantId,
brand_id: row.brandId,
name: row.name,
subject: null,
body_text: null,
@@ -84,7 +84,7 @@ export async function getHarvestReachCampaigns(
}
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select()
.from(campaigns)
@@ -118,13 +118,13 @@ export async function getCampaignAnalytics(
try {
const rows = campaignId
? await withTenant(brandId, (db) =>
? await withBrand(brandId, (db) =>
db
.select()
.from(campaigns)
.where(eq(campaigns.id, campaignId)),
)
: await withTenant(brandId, (db) =>
: await withBrand(brandId, (db) =>
db
.select()
.from(campaigns)
+3 -3
View File
@@ -2,7 +2,7 @@
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
export type ProductOption = {
@@ -27,7 +27,7 @@ export async function getProductsForSegmentPicker(
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({
id: products.id,
@@ -36,7 +36,7 @@ export async function getProductsForSegmentPicker(
active: products.active,
})
.from(products)
.where(and(eq(products.tenantId, brandId), eq(products.active, true))),
.where(and(eq(products.brandId, brandId), eq(products.active, true))),
);
return rows.map((r) => ({
id: r.id,
+11 -11
View File
@@ -2,7 +2,7 @@
import { and, eq, ilike, or, SQL, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { customers, brandSettings } from "@/db/schema";
import type { AudienceRules } from "@/actions/communications/campaigns";
@@ -55,7 +55,7 @@ export type Segment = {
export type CustomerSample = {
id: string;
email: string;
name: string;
fullName: string | null;
tags: string[];
phone: string | null;
};
@@ -70,11 +70,11 @@ function isSegmentList(v: unknown): v is Segment[] {
}
async function loadSegments(brandId: string): Promise<Segment[]> {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1),
);
const raw = (rows[0]?.flags ?? {}) as Record<string, unknown>;
@@ -83,11 +83,11 @@ async function loadSegments(brandId: string): Promise<Segment[]> {
}
async function saveSegments(brandId: string, segments: Segment[]): Promise<void> {
await withTenant(brandId, async (db) => {
await withBrand(brandId, async (db) => {
const existing = await db
.select({ flags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1);
const baseFlags = (existing[0]?.flags ?? {}) as Record<string, unknown>;
const nextFlags: Record<string, unknown> = {
@@ -97,7 +97,7 @@ async function saveSegments(brandId: string, segments: Segment[]): Promise<void>
await db
.update(brandSettings)
.set({ featureFlags: nextFlags, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
.where(eq(brandSettings.brandId, brandId));
});
}
@@ -221,15 +221,15 @@ export async function previewSegmentWithCustomers(
}
void rules;
const conds: SQL[] = [eq(customers.tenantId, brandId), eq(customers.emailOptIn, true)];
const conds: SQL[] = [eq(customers.brandId, brandId), eq(customers.emailOptIn, true)];
try {
const [samples, counts] = await withTenant(brandId, async (db) => {
const [samples, counts] = await withBrand(brandId, async (db) => {
const sample = await db
.select({
id: customers.id,
email: customers.email,
name: customers.name,
fullName: customers.fullName,
phone: customers.phone,
})
.from(customers)
@@ -247,7 +247,7 @@ export async function previewSegmentWithCustomers(
sample_customers: samples.map((s) => ({
id: s.id,
email: s.email ?? "",
name: s.name,
fullName: s.fullName,
tags: [],
phone: s.phone,
})),
+9 -10
View File
@@ -2,7 +2,7 @@
import { and, eq } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { stops } from "@/db/schema";
export type StopOption = {
@@ -51,29 +51,28 @@ export async function getStopsForSegmentPicker(
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) return [];
try {
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({
id: stops.id,
name: stops.name,
address: stops.address,
schedule: stops.schedule,
date: stops.date,
time: stops.time,
})
.from(stops)
.where(
stopId
? and(eq(stops.tenantId, brandId), eq(stops.id, stopId))
: eq(stops.tenantId, brandId),
? and(eq(stops.brandId, brandId), eq(stops.id, stopId))
: eq(stops.brandId, brandId),
),
);
const now = Date.now();
return rows.map((r) => {
const { city, state, zip } = parseAddress(r.address);
const sched = Array.isArray(r.schedule) ? r.schedule : [];
const first = sched[0] as { date?: string; time?: string } | undefined;
const dateStr = first?.date ?? "";
const timeStr = first?.time ?? "";
const { city, state, zip } = parseAddress(r.address ?? "");
const dateStr = r.date ?? "";
const timeStr = r.time ?? "";
const ts = dateStr ? new Date(dateStr).getTime() : NaN;
const isUpcoming = Number.isFinite(ts) ? ts >= now : false;
const isPast = Number.isFinite(ts) ? ts < now : false;
+5 -5
View File
@@ -50,7 +50,7 @@ export async function importOrdersBatch(
// Fetch product prices for the brand.
const productRes = await pool.query<{ id: string; price_cents: number }>(
`SELECT id, price_cents FROM products WHERE tenant_id = $1 AND id = ANY($2::uuid[])`,
`SELECT id, price_cents FROM products WHERE brand_id = $1 AND id = ANY($2::uuid[])`,
[brandId, productIds]
);
const priceMap = new Map(productRes.rows.map((p) => [p.id, p.price_cents]));
@@ -76,11 +76,11 @@ export async function importOrdersBatch(
// Insert in a single transaction: customers + orders + order_items.
await withTx(async (client) => {
// Upsert customer by (tenant_id, email).
// Upsert customer by (brand_id, email).
const customerRes = await client.query<{ id: string }>(
`INSERT INTO customers (tenant_id, name, email, phone, email_opt_in)
`INSERT INTO customers (brand_id, name, email, phone, email_opt_in)
VALUES ($1, $2, $3, $4, true)
ON CONFLICT (tenant_id, email) WHERE email IS NOT NULL
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL
DO UPDATE SET name = EXCLUDED.name, phone = EXCLUDED.phone, updated_at = now()
RETURNING id`,
[brandId, order.customer_name, order.customer_email || null, order.customer_phone || null]
@@ -88,7 +88,7 @@ export async function importOrdersBatch(
const customerId = customerRes.rows[0]?.id ?? null;
const orderRes = await client.query<{ id: string }>(
`INSERT INTO orders (tenant_id, customer_id, total_cents, status, fulfillment)
`INSERT INTO orders (brand_id, customer_id, total_cents, status, fulfillment)
VALUES ($1, $2, $3, 'pending', $4)
RETURNING id`,
[brandId, customerId, totalCents, fulfillment]
+3 -3
View File
@@ -1,7 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
export type ImportProductsResult =
@@ -45,9 +45,9 @@ export async function importProductsBatch(
continue;
}
try {
await withTenant(brandId, (db) =>
await withBrand(brandId, (db) =>
db.insert(products).values({
tenantId: brandId,
brandId: brandId,
name: p.name,
description: p.description ?? null,
priceCents,
+1 -61
View File
@@ -2,9 +2,8 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { mockOrders, mockStops } from "@/lib/mock-data";
type AdminOrder = {
export type AdminOrder = {
id: string;
customer_name: string;
customer_email: string | null;
@@ -106,46 +105,7 @@ type AdminOrderDetail = {
}>;
};
// Mock orders for UI review
const mockAdminOrders: AdminOrder[] = mockOrders.map((o: any) => ({
id: o.id,
customer_name: o.customer_name,
customer_email: o.customer_email,
customer_phone: o.customer_phone,
stop_id: o.stop_id,
status: o.status,
subtotal: o.subtotal,
pickup_complete: o.pickup_complete,
pickup_completed_at: o.pickup_completed_at,
pickup_completed_by: null,
created_at: o.created_at,
payment_processor: o.payment_processor,
stops: o.stops,
order_items: o.order_items,
}));
const mockAdminStops: AdminStop[] = mockStops.map((s: any) => ({
id: s.id,
city: s.city,
state: s.state,
date: s.date,
location: s.location,
brand_id: s.brand_id,
}));
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export async function getAdminOrders(): Promise<AdminOrdersResponse> {
// Return mock data in mock mode
if (useMockData) {
return {
success: true,
orders: mockAdminOrders,
stops: mockAdminStops,
error: null,
};
}
const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null;
@@ -193,26 +153,6 @@ export async function getAdminPickedUpOrders(): Promise<AdminOrder[]> {
}
export async function getAdminOrderDetail(orderId: string): Promise<AdminOrderDetail | null> {
// Return mock order detail in mock mode
if (useMockData) {
const order = mockAdminOrders.find((o) => o.id === orderId);
if (!order) return null;
return {
...order,
discount_amount: null,
tax_amount: order.subtotal * 0.08,
tax_rate: 0.08,
tax_location: "Colorado",
discount_reason: null,
internal_notes: null,
payment_status: "paid",
payment_transaction_id: `txn_mock_${orderId}`,
refunded_amount: 0,
refund_reason: null,
refunds: [],
};
}
const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? null;
+7 -1
View File
@@ -25,8 +25,14 @@ export type AdminCreateOrderInput = {
discount_reason?: string | null;
};
// Type for the created order from the RPC
export type CreatedOrder = {
id: string;
[key: string]: unknown;
};
export type AdminCreateOrderResult =
| { success: true; orderId: string; order: any }
| { success: true; orderId: string; order: CreatedOrder }
| { success: false; error: string };
export async function createAdminOrder(
+3 -25
View File
@@ -1,12 +1,9 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateProductResult =
| { success: true; id: string }
| { success: false; error: string };
@@ -32,25 +29,6 @@ export async function createProduct(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
const mockProducts = getMockTableData("products") as any[];
const newId = `mock-prod-${Date.now()}`;
const newProduct = {
id: newId,
name: data.name,
description: data.description,
price: data.price,
type: data.type,
is_active: data.active,
image_url: data.image_url ?? null,
is_taxable: data.is_taxable,
pickup_type: data.pickup_type ?? "scheduled_stop",
brand_id: brandId,
};
mockProducts.push(newProduct);
return { success: true, id: newId };
}
// The new schema stores products with `price_cents` (integer) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
@@ -62,11 +40,11 @@ export async function createProduct(
}
try {
const inserted = await withTenant(brandId, async (db) => {
const inserted = await withBrand(brandId, async (db) => {
const [row] = await db
.insert(products)
.values({
tenantId: brandId,
brandId: brandId,
name: data.name,
description: data.description ?? null,
priceCents,
+3 -10
View File
@@ -1,13 +1,10 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq } from "drizzle-orm";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateProductResult =
| { success: true }
| { success: false; error: string };
@@ -34,10 +31,6 @@ export async function updateProduct(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
return { success: true };
}
// The new schema has `price_cents` (integer cents) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
@@ -48,7 +41,7 @@ export async function updateProduct(
}
try {
await withTenant(brandId, (db) =>
await withBrand(brandId, (db) =>
db
.update(products)
.set({
@@ -58,7 +51,7 @@ export async function updateProduct(
active: data.active,
updatedAt: new Date(),
})
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
.where(and(eq(products.id, productId), eq(products.brandId, brandId)))
);
return { success: true };
} catch (err) {
+14 -19
View File
@@ -1,20 +1,14 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { productImages } from "@/db/schema";
import { uploadObject, BUCKETS } from "@/lib/storage";
export type UploadProductImageResult =
| { success: true; imageUrl: string }
| { success: false; error: string };
// TODO(migration): product images in the new SaaS schema live in the
// `product_images` table (storage_key + position + alt_text) backed by
// the `files` table. Supabase Storage is gone, so we no longer upload
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
// upload images should write to the `files` table via an S3-compatible
// backend (still TODO). This stub persists the intended storage key as
// a record so the UI can continue to render an image URL placeholder.
export async function uploadProductImage(
productId: string,
file: File
@@ -33,16 +27,16 @@ export async function uploadProductImage(
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
// Without a configured object store, we cannot actually upload bytes.
// We still record the planned storage key in `product_images` so the
// schema-level FK + ordering are exercised. The actual upload will be
// re-introduced when the S3-compatible store lands.
if (productId === "__NEW__") {
return { success: false, error: "Object store not configured; cannot upload images yet" };
}
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
const buffer = Buffer.from(await file.arrayBuffer());
const imageUrl = await uploadObject({
bucket: BUCKETS.PRODUCTS,
key: storageKey,
body: buffer,
contentType: file.type,
});
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.insert(productImages).values({
productId,
storageKey,
@@ -50,7 +44,8 @@ export async function uploadProductImage(
altText: null,
})
);
return { success: true, imageUrl: `/storage/${storageKey}` };
return { success: true, imageUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
@@ -66,7 +61,7 @@ export async function deleteProductImage(
// from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.delete(productImages).where(eq(productImages.productId, productId))
);
return { success: true };
+2 -2
View File
@@ -84,7 +84,7 @@ export type CampaignActivityRow = {
/** Substitute the brandId into the SQL — null = platform admin (all brands). */
function brandClause(brandId: string | null, tableAlias = "o"): string {
return brandId ? `AND ${tableAlias}.tenant_id = $3::uuid` : "";
return brandId ? `AND ${tableAlias}.brand_id = $3::uuid` : "";
}
function brandParams(brandId: string | null): unknown[] {
@@ -235,7 +235,7 @@ export async function getContactGrowthReport(range: DateRange, brandId: string |
FROM generate_series($1::date, $2::date, '1 day'::interval) d
LEFT JOIN customers c
ON c.created_at::date = d::date
${effectiveBrandId ? "AND c.tenant_id = $3::uuid" : ""}
${effectiveBrandId ? "AND c.brand_id = $3::uuid" : ""}
GROUP BY d::date
ORDER BY d::date DESC`,
[range.start, range.end, ...brandParams(effectiveBrandId)]
+5 -5
View File
@@ -1,7 +1,7 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
@@ -36,11 +36,11 @@ export async function toggleBrandFeature(
}
try {
await withTenant(brandId, async (db) => {
await withBrand(brandId, async (db) => {
const existing = await db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1);
const current = (existing[0]?.featureFlags ?? {}) as Record<string, unknown>;
const next = { ...current, [featureKey]: enabled };
@@ -48,12 +48,12 @@ export async function toggleBrandFeature(
// No row yet — bootstrap with the brand name from tenants.
await db
.insert(brandSettings)
.values({ tenantId: brandId, brandName: "Brand", featureFlags: next });
.values({ brandId: brandId, featureFlags: next });
} else {
await db
.update(brandSettings)
.set({ featureFlags: next, updatedAt: new Date() })
.where(eq(brandSettings.tenantId, brandId));
.where(eq(brandSettings.brandId, brandId));
}
});
+3 -3
View File
@@ -69,7 +69,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
status: string;
subtotal: number;
created_at: string;
tenant_id: string;
brand_id: string;
}>(
`SELECT
o.id::text AS id,
@@ -79,7 +79,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
o.status,
o.total_cents::float / 100.0 AS subtotal,
o.placed_at::text AS created_at,
o.tenant_id::text AS tenant_id
o.brand_id::text AS brand_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.fulfillment IN ('ship', 'mixed')
@@ -97,7 +97,7 @@ export async function getShippingOrders(): Promise<GetShippingOrdersResult> {
shipping_status: "pending",
tracking_number: null,
created_at: r.created_at,
brand_id: r.tenant_id,
brand_id: r.brand_id,
order_items: [],
}));
+5 -5
View File
@@ -135,10 +135,10 @@ export async function createFedExShipment(
// than fabricate a fake address.
const { rows: orderRows } = await pool.query<{
id: string;
tenant_id: string | null;
brand_id: string | null;
status: string;
}>(
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
`SELECT id::text AS id, brand_id::text AS brand_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
);
@@ -147,10 +147,10 @@ export async function createFedExShipment(
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
}
+5 -5
View File
@@ -165,10 +165,10 @@ export async function getFedExRates(
// meaningful error rather than silently call the API with junk.
const { rows: orderRows } = await pool.query<{
id: string;
tenant_id: string | null;
brand_id: string | null;
status: string;
}>(
`SELECT id::text AS id, tenant_id::text AS tenant_id, status
`SELECT id::text AS id, brand_id::text AS brand_id, status
FROM orders WHERE id = $1 LIMIT 1`,
[orderId]
);
@@ -176,10 +176,10 @@ export async function getFedExRates(
if (!order) return { success: false, error: "Order not found" };
// The order's brand is the source of truth. Validate the admin has access.
if (order.tenant_id) {
try { assertBrandAccess(adminUser, order.tenant_id); } catch { return { success: false, error: "Brand access denied" }; }
if (order.brand_id) {
try { assertBrandAccess(adminUser, order.brand_id); } catch { return { success: false, error: "Brand access denied" }; }
}
const effectiveBrandId = order.tenant_id ?? adminUser.brand_id ?? null;
const effectiveBrandId = order.brand_id ?? adminUser.brand_id ?? null;
if (!effectiveBrandId) {
return { success: false, error: "Brand required for shipping" };
}
+6 -6
View File
@@ -2,11 +2,11 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
function getSquareBaseUrl(accessToken: string) {
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
@@ -16,7 +16,7 @@ async function getSquareCatalogItemVariation(
accessToken: string,
catalogObjectId: string
) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
{
@@ -41,7 +41,7 @@ async function batchUpdateSquareInventory(
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
}>
) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/changes/batch-create`,
{
@@ -118,7 +118,7 @@ export async function syncInventoryToSquare(
try {
// Fetch product details from RC
const productIds = items.map((i) => i.productId);
const rows = await withTenant(brandId, (db) =>
const rows = await withBrand(brandId, (db) =>
db
.select({ id: products.id, name: products.name })
.from(products)
@@ -178,7 +178,7 @@ export async function syncInventoryFromSquare(brandId: string): Promise<SyncResu
try {
// Fetch Square inventory counts for the location
const baseUrl = getSquareBaseUrl(settings.square_access_token);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
{
+3 -3
View File
@@ -4,7 +4,7 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { pool } from "@/lib/db";
function getSquareBaseUrl(accessToken: string) {
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
@@ -15,7 +15,7 @@ async function fetchSquarePayments(
locationId: string,
since: string
) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/payments/list`,
{
@@ -41,7 +41,7 @@ async function fetchSquarePayments(
}
async function fetchSquareOrder(accessToken: string, orderId: string) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/orders/${orderId}`,
{
+3 -17
View File
@@ -2,7 +2,6 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { SquareClient, SquareEnvironment, type BaseClientOptions } from "square";
import { pool } from "@/lib/db";
export type SquareCatalogItem = {
@@ -14,19 +13,7 @@ export type SquareCatalogItem = {
imageUrl?: string;
};
function getSquareClient(accessToken: string) {
const env = process.env.SQUARE_ENVIRONMENT === "production"
? SquareEnvironment.Production
: SquareEnvironment.Sandbox;
const config: BaseClientOptions = {
token: accessToken,
environment: env,
};
return new SquareClient(config);
}
function getSquareBaseUrl(accessToken: string) {
function getSquareBaseUrl() {
const env = process.env.SQUARE_ENVIRONMENT === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
@@ -34,7 +21,7 @@ function getSquareBaseUrl(accessToken: string) {
}
async function fetchSquareCatalog(accessToken: string, cursor?: string) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/catalog/list`,
{
@@ -67,7 +54,7 @@ async function upsertSquareCatalogItem(
},
existingItemId?: string
) {
const baseUrl = getSquareBaseUrl(accessToken);
const baseUrl = getSquareBaseUrl();
const body: Record<string, unknown> = {
idempotency_key: crypto.randomUUID(),
item: {
@@ -130,7 +117,6 @@ export async function syncProductsToSquare(brandId: string): Promise<SyncResult>
return { success: false, synced: 0, errors: ["Square not connected"] };
}
const client = getSquareClient(settings.square_access_token);
const errors: string[] = [];
let synced = 0;
-8
View File
@@ -3,9 +3,6 @@
import { revalidateTag } from "next/cache";
import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { getMockTableData } from "@/lib/mock-data";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateStopResult =
| { success: true; id: string }
@@ -33,11 +30,6 @@ export async function createStop(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
const mockStops = getMockTableData("stops") as Array<{ id: string }>;
return { success: true, id: `mock-stop-${Date.now()}` };
}
// `admin_create_stop` is a SECURITY DEFINER RPC that bypasses the
// block_stops_mutations RLS policy. It returns either {success,
// stop_id} or {success:false, error}. Migration 202.
-6
View File
@@ -50,12 +50,6 @@ export async function getStopDetails(stopId: string): Promise<StopDetailsResult>
return { success: false, error: "Not authorized" };
}
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
if (useMockData) {
return { success: false, error: "Stop not found" };
}
// 1. Stop + brand — legacy schema, raw SQL (the Drizzle stops table
// maps to the new schema which doesn't have city/state/date/etc).
const stopRes = await pool.query<StopDetail & { brand_name: string; brand_slug: string }>(
-6
View File
@@ -4,8 +4,6 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import { logAuditEvent } from "@/actions/audit";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateStopResult =
| { success: true }
| { success: false; error: string };
@@ -33,10 +31,6 @@ export async function updateStop(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
return { success: true };
}
const slug = `${data.city.toLowerCase().replace(/\s+/g, "-")}-${data.date}`;
// Direct UPDATE on the legacy stops table — the new-schema Drizzle
+8 -5
View File
@@ -4,6 +4,9 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { pool } from "@/lib/db";
import Stripe from "stripe";
// Stripe API version type
type StripeApiVersion = "2026-05-27.dahlia";
/**
* Get Stripe Connect status for a brand.
* Checks if brand has a stripe_user_id (connected account).
@@ -38,7 +41,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
return { is_connected: true, account_id: stripeUserId };
}
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const account = await stripe.accounts.retrieve(stripeUserId);
return {
@@ -48,7 +51,7 @@ export async function getStripeConnectStatus(brandId: string): Promise<{
payouts_enabled: account.payouts_enabled,
details_submitted: account.details_submitted,
};
} catch (err) {
} catch {
// If we can't verify, assume connected but with stale data
return {
is_connected: true,
@@ -82,7 +85,7 @@ export async function createStripeConnectLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
// Create Express account
@@ -144,7 +147,7 @@ export async function refreshStripeConnectLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const origin = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const accountLink = await stripe.accountLinks.create({
@@ -234,7 +237,7 @@ export async function createStripeDashboardLink(brandId: string): Promise<{
}
try {
const stripe = new Stripe(stripeKey, { apiVersion: "2026-04-22.dahlia" as any });
const stripe = new Stripe(stripeKey, { apiVersion: "2026-05-27.dahlia" as StripeApiVersion });
const loginLink = await stripe.accounts.createLoginLink(status.account_id);
return {
+4 -5
View File
@@ -2,7 +2,7 @@
import Stripe from "stripe";
import { eq } from "drizzle-orm";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { brandSettings } from "@/db/schema";
import { pool } from "@/lib/db";
@@ -64,8 +64,7 @@ export async function calculateOrderTax(params: {
const stripe = new Stripe(stripeKey);
// Build Stripe line items for tax calculation — only taxable items
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const lineItems: any[] = params.items
const lineItems: Stripe.Tax.CalculationCreateParams.LineItem[] = params.items
.filter((item) => item.is_taxable !== false)
.map((item) => ({
amount: Math.round(item.price * item.quantity * 100), // cents
@@ -120,11 +119,11 @@ export async function calculateOrderTax(params: {
*/
async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings | null> {
try {
const rows = await withTenant(brandId, async (db) =>
const rows = await withBrand(brandId, async (db) =>
db
.select({ featureFlags: brandSettings.featureFlags })
.from(brandSettings)
.where(eq(brandSettings.tenantId, brandId))
.where(eq(brandSettings.brandId, brandId))
.limit(1),
);
const flags = rows[0]?.featureFlags as
+7 -114
View File
@@ -1,7 +1,6 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
// TODO(migration): the time-tracking admin RPCs (get_time_tracking_workers,
// create_time_worker, reset_time_worker_pin, update_time_worker,
@@ -13,15 +12,10 @@ import { mockWorkers, mockTasks, mockTimeEntries } from "@/lib/mock-data";
// the underlying tables (`time_workers`, `time_tasks`, `time_logs`,
// `time_tracking_settings`, `time_tracking_notification_log`) were
// not carried over into the SaaS rebuild's `db/schema/`. The actions
// below preserve the original signatures and return mock data when
// `NEXT_PUBLIC_USE_MOCK_DATA === "true"`, but the real RPC paths now
// return empty/empty-list results. To bring time tracking back, add
// below return empty results. To bring time tracking back, add
// the tables to `db/schema/` and re-implement against Drizzle. See
// `actions/route-trace/lots.ts` for the same pattern.
// Mock mode flag - only enabled when explicitly set
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
// ── Types ─────────────────────────────────────────────────────────────────────
export type TimeWorker = {
@@ -69,21 +63,7 @@ export type TimeSummary = {
// ── Workers ───────────────────────────────────────────────────────────────────
export async function getTimeTrackingWorkers(brandId: string): Promise<TimeWorker[]> {
if (useMockData) {
return mockWorkers.map(w => ({
id: w.id,
brand_id: brandId,
name: w.name,
role: w.role,
lang: w.language,
pin: "0000",
active: w.is_active,
last_used_at: null,
created_at: new Date().toISOString(),
worker_number: null,
}));
}
export async function getTimeTrackingWorkers(_brandId: string): Promise<TimeWorker[]> {
// Time tracking tables not in SaaS rebuild — return empty list.
return [];
}
@@ -125,17 +105,7 @@ export async function deleteTimeWorker(_workerId: string): Promise<{ success: bo
// ── Tasks ────────────────────────────────────────────────────────────────────
export async function getTimeTrackingTasks(brandId: string, activeOnly = false): Promise<TimeTask[]> {
if (useMockData) {
return mockTasks.map(t => ({
id: t.id,
name: t.name_en,
name_es: t.name_es,
unit: t.unit,
active: true,
sort_order: t.sort_order,
}));
}
export async function getTimeTrackingTasks(_brandId: string, _activeOnly = false): Promise<TimeTask[]> {
return [];
}
@@ -173,7 +143,7 @@ export async function deleteTimeTask(_taskId: string): Promise<{ success: boolea
// ── Time Logs ─────────────────────────────────────────────────────────────────
export async function getWorkerTimeLogs(
brandId: string,
_brandId: string,
_options: {
workerId?: string;
taskId?: string;
@@ -183,30 +153,6 @@ export async function getWorkerTimeLogs(
offset?: number;
} = {}
): Promise<TimeLog[]> {
if (useMockData) {
// Filter by worker, task, date range
let entries = mockTimeEntries.filter(e => e.brand_id === brandId);
if (_options.workerId) entries = entries.filter(e => e.worker_id === _options.workerId);
return entries.map(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id);
const task = mockTasks.find(t => t.id === e.task_id);
return {
id: e.id,
worker_id: e.worker_id,
worker_name: worker?.name ?? "Unknown",
task_id: e.task_id,
task_name: task?.name_en ?? "Unknown",
clock_in: `${e.date}T09:00:00Z`,
clock_out: `${e.date}T${9 + e.hours}:00:00Z`,
lunch_break_minutes: 0,
notes: null,
submitted_via: "web",
total_minutes: Math.round(e.hours * 60),
created_at: new Date().toISOString(),
};
});
}
return [];
}
@@ -230,43 +176,10 @@ export async function deleteWorkerTimeLog(_logId: string): Promise<{ success: bo
}
export async function getTimeTrackingSummary(
brandId: string,
_brandId: string,
_start: string,
_end: string
): Promise<TimeSummary> {
if (useMockData) {
const entries = mockTimeEntries.filter(e => e.brand_id === brandId);
// Calculate by worker
const workerMap = new Map<string, { id: string; name: string; total_hours: number; entry_count: number }>();
entries.forEach(e => {
const worker = mockWorkers.find(w => w.id === e.worker_id);
const existing = workerMap.get(e.worker_id) || { id: e.worker_id, name: worker?.name ?? "Unknown", total_hours: 0, entry_count: 0 };
existing.total_hours += e.hours;
existing.entry_count += 1;
workerMap.set(e.worker_id, existing);
});
// Calculate by task
const taskMap = new Map<string, { id: string; name: string; name_es: string | null; total_hours: number; entry_count: number }>();
entries.forEach(e => {
const task = mockTasks.find(t => t.id === e.task_id);
const existing = taskMap.get(e.task_id) || { id: e.task_id, name: task?.name_en ?? "Unknown", name_es: task?.name_es ?? null, total_hours: 0, entry_count: 0 };
existing.total_hours += e.hours;
existing.entry_count += 1;
taskMap.set(e.task_id, existing);
});
return {
by_worker: Array.from(workerMap.values()),
by_task: Array.from(taskMap.values()),
totals: {
entry_count: entries.length,
total_hours: entries.reduce((sum, e) => sum + e.hours, 0),
open_count: 0,
},
};
}
return { by_worker: [], by_task: [], totals: { entry_count: 0, total_hours: 0, open_count: 0 } };
}
@@ -291,27 +204,7 @@ export type TimeTrackingSettings = {
brand_name: string;
};
export async function getTimeTrackingSettings(brandId: string): Promise<TimeTrackingSettings | null> {
if (useMockData) {
return {
id: "settings-mock",
brand_id: brandId,
pay_period_start_day: 0,
pay_period_length_days: 7,
daily_overtime_threshold: 8,
weekly_overtime_threshold: 40,
overtime_multiplier: 1.5,
overtime_notifications: true,
notification_emails: ["admin@tuxedocorn.com"],
notification_sms_numbers: [],
enable_daily_alerts: true,
enable_weekly_alerts: true,
daily_alert_threshold: 8,
weekly_alert_threshold: 40,
send_end_of_period_summary: true,
brand_name: "Tuxedo Corn",
};
}
export async function getTimeTrackingSettings(_brandId: string): Promise<TimeTrackingSettings | null> {
// Real RPC not in SaaS rebuild.
return null;
}
@@ -362,4 +255,4 @@ export async function getTimeTrackingNotificationLog(
_limit = 100
): Promise<NotificationLogEntry[]> {
return [];
}
}
-1
View File
@@ -38,6 +38,5 @@ export async function wholesaleLogoutAction(): Promise<{ success: true } | { suc
const cookieStore = await cookies();
// Best-effort cookie cleanup so a returning customer doesn't see stale state
cookieStore.delete("wholesale_session");
cookieStore.delete("sb-wnzkhezyhnfzhkhiflrp-auth-token");
return { success: true };
}
+12 -3
View File
@@ -1,5 +1,5 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { getAdminOrderDetail } from "@/actions/orders";
import { getAdminOrderDetail, type AdminOrder } from "@/actions/orders";
import OrderEditForm from "@/components/admin/OrderEditForm";
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
import OrderPickupAction from "@/components/admin/OrderPickupAction";
@@ -14,6 +14,15 @@ type OrderDetailPageProps = {
}>;
};
type OrderItem = {
id: string;
quantity: number;
price: number | string;
products?: {
name: string;
} | null;
};
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
@@ -139,7 +148,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
{order.order_items && order.order_items.length > 0 ? (
<div className="mt-4 divide-y divide-stone-100">
{order.order_items.map((item: any) => (
{order.order_items.map((item: OrderItem) => (
<div
key={item.id}
className="flex items-center justify-between py-3"
@@ -214,7 +223,7 @@ export default async function OrderDetailPage({ params }: OrderDetailPageProps)
<p className="mt-1 text-sm text-stone-500">Update customer details, pricing, and status</p>
<div className="mt-5">
<OrderEditForm order={order as any} brandId={brandId} />
<OrderEditForm order={order as unknown as Parameters<typeof OrderEditForm>[0]["order"]} brandId={brandId} />
</div>
</div>
+5 -5
View File
@@ -53,12 +53,12 @@ export default async function AdminOrdersPage() {
}
const { data: prods } = await prodQuery;
brandProducts = (prods ?? []).map((p: any) => ({
id: p.id,
name: p.name,
brandProducts = (prods ?? []).map((p) => ({
id: String(p.id),
name: String(p.name ?? ""),
price: Number(p.price),
type: p.type ?? null,
active: p.active ?? true,
type: p.type as string | null ?? null,
active: Boolean(p.active ?? true),
}));
} catch {
// non-fatal for the orders list
+1 -1
View File
@@ -38,7 +38,7 @@ export default async function AdminPage() {
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
dashboardBrandId = String(firstBrand.id);
}
} catch (err) {
console.error("[admin/page] supabase brands lookup failed:", err);
+5 -2
View File
@@ -4,6 +4,10 @@ import { getAdminOrders } from "@/actions/orders";
export const dynamic = "force-dynamic";
// Pre-compute cutoff time to avoid impure function in render
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();
@@ -26,8 +30,7 @@ export default async function DriverPickupPage() {
(o) =>
o.pickup_complete &&
o.pickup_completed_at &&
new Date(o.pickup_completed_at) >
new Date(Date.now() - 72 * 60 * 60 * 1000)
new Date(o.pickup_completed_at) > pickedUpCutoff
);
return (
+26 -6
View File
@@ -11,12 +11,32 @@ type ProductDetailPageProps = {
}>;
};
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 }] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
supabase.from("brands").select("id, name, slug"),
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
]);
const adminUser = await getAdminUser();
@@ -109,14 +129,14 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
product={{
id: product.id,
name: product.name,
description: product.description,
description: product.description ?? "",
price: Number(product.price),
type: product.type,
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
+12 -12
View File
@@ -1,6 +1,6 @@
import { asc, eq, sql } from "drizzle-orm";
import { withPlatformAdmin, withTenant } from "@/db/client";
import { products, productImages, tenants } from "@/db/schema";
import { withPlatformAdmin, withBrand } from "@/db/client";
import { products, productImages, brands } from "@/db/schema";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -53,14 +53,14 @@ export default async function AdminProductsPage() {
const isPlatformAdmin = adminUser.role === "platform_admin";
// Platform admins need a brand picker for new products
let brands: { id: string; name: string }[] = [];
let brandOptions: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
brandOptions = result.brands ?? [];
}
// Query products + their first image. The new SaaS schema:
// * renamed `brand_id` → `tenant_id` on products
// * renamed `brand_id` → `brand_id` on products
// * renamed `price` (numeric) → `price_cents` (integer)
// * moved `image_url` off the products table into a separate
// `product_images` table (keyed by `product_id` + `position`)
@@ -71,7 +71,7 @@ export default async function AdminProductsPage() {
let productsList: ProductRow[] = [];
let queryError: string | null = null;
try {
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
db
.select({
id: products.id,
@@ -79,13 +79,13 @@ export default async function AdminProductsPage() {
description: products.description,
priceCents: products.priceCents,
active: products.active,
tenantId: products.tenantId,
tenantName: tenants.name,
brandId: products.brandId,
brandName: brands.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(tenants, eq(tenants.id, products.tenantId))
.leftJoin(brands, eq(brands.id, products.brandId))
// Pull only the lowest-position image per product. The
// `position` index on product_images (product_id, position) makes
// this cheap; a real-world scale would replace it with a
@@ -105,7 +105,7 @@ export default async function AdminProductsPage() {
const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db))
: brandId
? await withTenant(brandId, (db) => baseQuery(db))
? await withBrand(brandId, (db) => baseQuery(db))
: [];
// Resolve each row's first image to a public URL. Until object-store
@@ -120,7 +120,7 @@ export default async function AdminProductsPage() {
type: "pickup", // legacy column not in new schema
active: r.active,
image_url: r.firstImageKey, // storage key, not a public URL yet
brand_id: r.tenantId,
brand_id: r.brandId,
is_taxable: false, // legacy column not in new schema
available_from: null,
available_until: null,
@@ -147,7 +147,7 @@ export default async function AdminProductsPage() {
<ProductsClient
products={productsList}
brandId={brandId ?? undefined}
brands={brands}
brands={brandOptions}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function ReportsPage() {
const { data: brands } = await supabase
.from("brands")
.select("id, name")
.order("name");
.order("name") as unknown as { data: { id: string; name: string }[] | null };
const initialBrandId = isPlatformAdmin
? null
+2 -7
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
@@ -26,13 +25,9 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [detailResult, ordersResult] = await Promise.all([
@@ -68,4 +63,4 @@ export default async function LotDetailPage({ params }: { params: Promise<{ id:
</div>
</div>
);
}
}
+2 -7
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { getRouteTraceLots } from "@/actions/route-trace/lots";
@@ -22,13 +21,9 @@ export default async function LotsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
@@ -62,4 +57,4 @@ export default async function LotsPage() {
lots={allLots}
/>
);
}
}
+2 -8
View File
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags";
import {
@@ -41,14 +40,9 @@ export default async function RouteTraceDashboardPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode = !!devSession;
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
// Bypass feature check in dev mode (dev_session cookie present)
const enabled = isDevMode ? true : await isFeatureEnabled(effectiveBrandId, "route_trace");
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
@@ -110,4 +104,4 @@ export default async function RouteTraceDashboardPage() {
</div>
</main>
);
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function BillingPage({ params }: Props) {
.limit(1)
.single();
if (firstBrand?.id) {
resolvedBrandId = firstBrand.id;
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
+1 -1
View File
@@ -18,7 +18,7 @@ export default async function IntegrationsPage() {
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
return (
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function ShippingSettingsPage() {
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
: [];
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
@@ -65,7 +65,7 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
setError(null);
const result = await savePaymentSettings({
brandId,
provider: (settings?.provider as any) || null,
provider: (settings?.provider ?? null) as "stripe" | "square" | "manual" | null,
squareSyncEnabled,
squareInventoryMode,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { getPaymentSettings, type PaymentSettings } from "@/actions/payments";
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
@@ -26,7 +26,7 @@ export default async function SquareSyncSettingsPage() {
return (
<SquareSyncSettingsClient
settings={settings as any}
settings={settings as PaymentSettings}
logs={logs}
brandId={brandId}
/>
+46 -5
View File
@@ -13,6 +13,38 @@ type StopDetailPageProps = {
}>;
};
interface Stop {
id: string;
brand_id: string;
city: string;
state: string;
address: string | null;
zip: string | null;
date: string;
time: string;
cutoff_date: string | null;
cutoff_time: string | null;
status: string;
location: string;
slug: string;
active: boolean;
brands?: { name: string; slug: string };
}
interface Product {
id: string;
name: string;
type: string;
price: number;
image_url?: string | null;
}
interface ProductStop {
id: string;
product_id: string;
products?: Product;
}
export default async function StopDetailPage({ params }: StopDetailPageProps) {
const { id } = await params;
@@ -21,8 +53,8 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
.from("stops")
.select("*, brands(name, slug)")
.eq("id", id)
.single(),
supabase.from("brands").select("id, name, slug"),
.single() as unknown as { data: Stop | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: { id: string; name: string; slug: string }[] | null },
]);
const adminUser = await getAdminUser();
@@ -61,15 +93,24 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
.from("products")
.select("id, name, type, price")
.eq("brand_id", stop.brand_id)
.eq("active", true),
.eq("active", true) as unknown as { data: Product[] | null },
supabase
.from("product_stops")
.select("id, product_id, products(id, name, type, price)")
.eq("stop_id", id),
.eq("stop_id", id) as unknown as { data: ProductStop[] | null },
]);
const assignedProducts = (productStops ?? [])
.map((ps: any) => ps)
.map((ps: ProductStop) => ({
id: ps.id,
product_id: ps.product_id,
products: ps.products ? {
name: ps.products.name,
type: ps.products.type,
price: ps.products.price,
image_url: ps.products.image_url,
} : null,
}))
.filter(Boolean);
return (
+2 -2
View File
@@ -36,7 +36,7 @@ export default async function NewStopPage({
.from("stops")
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
.eq("id", duplicate)
.single();
.single() as unknown as { data: Stop | null };
duplicateFrom = data;
}
@@ -48,7 +48,7 @@ export default async function NewStopPage({
.from("products")
.select("id, name, type, price")
.eq("brand_id", brandId)
.eq("active", true),
.eq("active", true) as unknown as { data: { id: string; name: string; type: string; price: number }[] | null },
]);
return (
+18 -1
View File
@@ -6,6 +6,23 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { PageHeader } from "@/components/admin/design-system";
import { redirect } from "next/navigation";
interface Stop {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at: string | null;
brand_id: string;
address: string | null;
zip: string | null;
cutoff_time: string | null;
status: string;
brands: { name: string } | { name: string }[];
}
const StopIcon = () => (
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/>
@@ -49,7 +66,7 @@ export default async function AdminStopsPage() {
query = query.eq("brand_id", adminUser.brand_id);
}
const { data: stops, error } = await query;
const { data: stops, error } = await query as unknown as { data: Stop[] | null; error: { message: string } | null };
if (error) {
return (
+2 -2
View File
@@ -29,9 +29,9 @@ export default async function TaxesPage({ params }: Props) {
.from("brands")
.select("id")
.limit(1)
.single();
.single() as unknown as { data: { id: string } | null };
if (firstBrand?.id) {
resolvedBrandId = firstBrand.id;
resolvedBrandId = String(firstBrand.id);
} else {
return (
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
+3 -11
View File
@@ -1,6 +1,5 @@
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
import { getAdminUser } from "@/lib/admin-permissions";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/admin/design-system";
@@ -11,16 +10,9 @@ export const dynamic = "force-dynamic";
export default async function AdminTimeTrackingPage() {
const adminUser = await getAdminUser();
// Dev session bypass for platform admin
if (!adminUser) {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
if (devSession === "platform_admin" || devSession === "brand_admin") {
// Allow access in dev mode
} else {
redirect("/login");
}
redirect("/login");
}
const isPlatformAdmin = adminUser?.role === "platform_admin";
@@ -45,4 +37,4 @@ export default async function AdminTimeTrackingPage() {
</div>
</div>
);
}
}
+10 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
import { useRouter } from "next/navigation";
@@ -24,11 +24,7 @@ export default function WaterLogSettingsPage() {
const [alertPhone, setAlertPhone] = useState("");
const [alertsEnabled, setAlertsEnabled] = useState(false);
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
const loadSettings = useCallback(async () => {
setLoading(true);
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
if (data) {
@@ -42,7 +38,14 @@ export default function WaterLogSettingsPage() {
setAlertsEnabled(data.alerts_enabled ?? false);
}
setLoading(false);
}
}, []);
useEffect(() => {
const init = async () => {
await loadSettings();
};
init();
}, [loadSettings]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
+23 -15
View File
@@ -41,6 +41,15 @@ import { PageHeader, AdminButton, AdminSearchInput, AdminFilterTabs, AdminBadge
type Tab = "dashboard" | "customers" | "products" | "orders" | "settings";
// SVG Icon for Wholesale - defined at module level to prevent recreation on each render
function WholesaleIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
}
export default function WholesaleClient({ brandId }: { brandId: string }) {
const [tab, setTab] = useState<Tab>("dashboard");
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
@@ -101,13 +110,6 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
);
}
// SVG Icon for Wholesale
const WholesaleIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
const tabs = [
{ value: "dashboard", label: "Dashboard", count: undefined },
{ value: "customers", label: "Customers", count: customers.filter(c => c.account_status !== "pending_approval" && c.account_status !== "rejected").length },
@@ -116,10 +118,13 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
{ value: "settings", label: "Settings" },
];
// Declare icon reference for PageHeader
const pageIcon = <WholesaleIcon />;
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<PageHeader
icon={<WholesaleIcon />}
icon={pageIcon}
title="Wholesale Portal"
subtitle="Manage wholesale orders, customers, and products"
className="mb-0"
@@ -1843,13 +1848,7 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
onRefresh: () => void;
canManageSettings: boolean;
}) {
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
// Hooks must be called unconditionally - always declare them first
const [form, setForm] = useState({
requireApproval: settings?.require_approval ?? true,
minOrderAmount: settings?.min_order_amount ?? "",
@@ -1868,6 +1867,15 @@ function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }:
});
const [saving, setSaving] = useState(false);
// Permission check after hooks
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleSettings({
+5 -18
View File
@@ -1,25 +1,12 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import WholesaleClient from "./WholesaleClient";
export default async function WholesalePage() {
const cookieStore = await cookies();
const devSession = cookieStore.get("dev_session")?.value;
const isDevMode =
devSession === "platform_admin" ||
devSession === "brand_admin" ||
devSession === "store_employee";
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
if (!isDevMode) {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/login");
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
}
// Dev mode: platform_admin sees all brands, use first brand as default
const brandId = devSession === "platform_admin" ? "" : (cookieStore.get("dev_brand_id")?.value ?? "64294306-5f42-463d-a5e8-2ad6c81a96de");
return <WholesaleClient brandId={brandId} />;
}
const activeBrandId = await getActiveBrandId(adminUser);
return <WholesaleClient brandId={activeBrandId ?? ""} />;
}
+1 -1
View File
@@ -61,7 +61,7 @@ Use "trending" for product popularity questions.
Use "top_customers" for customer ranking questions.
Use "recent_orders" for recent order questions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Order Data: ${JSON.stringify(historicalData ?? [])}
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -63,7 +63,7 @@ ${JSON.stringify(sampleRows, null, 2)}
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -62,7 +62,7 @@ ${stopsList}
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -64,7 +64,7 @@ Customer Count: ${customerCount ?? "unknown"}
Analyze this stop's context and order history to recommend the optimal stop blast message.
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+13 -4
View File
@@ -1,4 +1,13 @@
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
// Neon Auth API route handles all auth endpoints via Better Auth server proxy.
// Mounted at /api/auth/* (sign-in, sign-out, session, callbacks, etc.)
import { NextRequest } from "next/server";
import { handlersGET, handlersPOST } from "@/lib/auth";
// Re-export as GET and POST for Next.js route handler
export const GET = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersGET(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
export const POST = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersPOST(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession, setUserPassword } from "@/lib/auth";
interface ChangePasswordBody {
password: string;
userId?: string;
}
export async function POST(req: NextRequest) {
let body: ChangePasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password, userId } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
// Get the current session
const { data: session } = await getSession();
const sessionUserId = session?.user?.id;
if (!sessionUserId) {
return NextResponse.json(
{ error: "Not authenticated." },
{ status: 401 }
);
}
// If userId is provided, require admin privileges (handled by updatePasswordAction)
// If no userId, the user is changing their own password
const targetUserId = userId ?? sessionUserId;
// Non-admin users can only change their own password
if (userId && userId !== sessionUserId) {
// For now, only admins can change other users' passwords
// This is handled by the updatePasswordAction in admin/password.ts
return NextResponse.json(
{ error: "Use the admin panel to change other users' passwords." },
{ status: 403 }
);
}
try {
const result = await setUserPassword({
userId: targetUserId,
newPassword: password,
});
if (result.error) {
console.error("[auth/change-password] Failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ChangeFailed",
message: result.error.message ?? "Failed to change password.",
},
{ status: 400 }
);
}
console.log("[auth/change-password] Password changed for user:", targetUserId);
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/change-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
interface ForgotPasswordBody {
email: string;
}
export async function POST(req: NextRequest) {
let body: ForgotPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { email } = body;
const trimmedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
if (!trimmedEmail) {
return NextResponse.json(
{ error: "Email is required." },
{ status: 400 }
);
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email: trimmedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Request failed:", result.error);
// Don't reveal whether the email exists or not for security
return NextResponse.json({ success: true });
}
console.log("[auth/forgot-password] Reset email sent to:", trimmedEmail);
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
// Don't reveal error details to the client
return NextResponse.json({ success: true });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { resetPassword } from "@/lib/auth";
interface ResetPasswordBody {
password: string;
}
export async function POST(req: NextRequest) {
let body: ResetPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
try {
const result = await resetPassword({
newPassword: password,
});
if (result.error) {
console.error("[auth/reset-password] Reset failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ResetFailed",
message: result.error.message ?? "Failed to reset password. The link may have expired.",
},
{ status: 400 }
);
}
console.log("[auth/reset-password] Password reset successful");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/reset-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { signIn } from "@/lib/auth";
interface SignInBody {
email: string;
password: string;
}
export async function POST(req: NextRequest) {
let body: SignInBody;
try {
body = await req.json();
} catch (err) {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = body?.email;
const password = body?.password;
if (!email || !password) {
return NextResponse.json(
{ error: "MissingCredentials", message: "Email and password are required." },
{ status: 400 }
);
}
try {
const result = await signIn.email({
email,
password,
});
console.log("[sign-in] signIn.email result:", JSON.stringify(result));
if (result.error) {
console.log("[sign-in] sign-in error:", result.error);
return NextResponse.json(
{ error: result.error.code ?? "SignInFailed", message: result.error.message ?? "Invalid email or password." },
{ status: 401 }
);
}
console.log("[sign-in] sign-in success, session should be set");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[sign-in] Unexpected error:", err);
return NextResponse.json(
{ error: "ServiceError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { signOut } from "@/lib/auth";
export async function POST() {
try {
await signOut();
// This line won't be reached because signOut() redirects
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/sign-out] Error:", err);
// Even if signOut throws, redirect to login
return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"));
}
}
+89 -28
View File
@@ -1,35 +1,96 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
import { sendCampaignEmail } from "@/lib/email-service";
export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
/**
* Cron: /api/cron/send-scheduled
* Runs daily at 09:00 (declared in vercel.json).
* Finds all campaigns with status='scheduled' and scheduled_at <= now(),
* sends each one to all opted-in contacts for that brand, then marks
* the campaign as 'sent'.
*
* Auth: Bearer token via CRON_SECRET header.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get("authorization") ?? "";
const expected = `Bearer ${process.env.CRON_SECRET ?? ""}`;
if (!expected || authHeader !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const response = await fetch(
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
);
const text = await response.text();
if (!response.ok) {
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
}
let results: unknown[] = [];
try {
const data = JSON.parse(text);
if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.results)) results = d.results as unknown[];
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
else if (Array.isArray(data)) results = data as unknown[];
}
} catch { /* ignore */ }
// 1. Find campaigns due to be sent
const { rows: campaigns } = await pool.query<{
id: string;
brand_id: string;
name: string;
subject: string | null;
body_html: string | null;
brand_name: string | null;
}>(
`SELECT id, brand_id, name, subject, body_html, brand_name
FROM communication_campaigns
WHERE status = 'scheduled'
AND scheduled_at IS NOT NULL
AND scheduled_at <= now()
ORDER BY scheduled_at ASC`
);
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
if (campaigns.length === 0) {
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results: [] });
}
const results: Array<{ campaignId: string; name: string; sent: number; errors: number }> = [];
for (const campaign of campaigns) {
if (!campaign.body_html) {
await pool.query(
`UPDATE communication_campaigns SET status = 'sent', sent_at = now() WHERE id = $1`,
[campaign.id]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent: 0, errors: 0 });
continue;
}
// 2. Find opted-in contacts for this brand
const { rows: contacts } = await pool.query<{ id: string; email: string | null; full_name: string | null }>(
`SELECT id, email, full_name
FROM communication_contacts
WHERE brand_id = $1
AND email_opt_in = true
AND email IS NOT NULL
AND unsubscribed_at IS NULL`,
[campaign.brand_id]
);
let sent = 0;
let errors = 0;
for (const contact of contacts) {
if (!contact.email) continue;
const ok = await sendCampaignEmail({
to: contact.email,
subject: campaign.subject ?? campaign.name,
html: campaign.body_html,
});
if (ok) sent++;
else errors++;
}
// 3. Mark campaign as sent
await pool.query(
`UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), recipient_count = $2
WHERE id = $1`,
[campaign.id, sent]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent, errors });
}
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
} catch (err) {
console.error("[cron/send-scheduled] Error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
+29 -28
View File
@@ -1,36 +1,37 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get("email") as string;
export async function POST(req: NextRequest) {
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
return NextResponse.json({ error: "Email is required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Neon Auth error:", result.error);
}
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
return NextResponse.json({ success: true });
}
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
});
// Always return 200 to avoid email enumeration — Supabase may still send or not
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -9,7 +9,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -20,13 +20,13 @@ export async function GET() {
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+5 -5
View File
@@ -41,9 +41,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
@@ -51,7 +51,7 @@ export async function GET(req: NextRequest) {
})
.from(orders)
.leftJoin(customers, eq(customers.id, orders.customerId))
.where(eq(orders.tenantId, brandId))
.where(eq(orders.brandId, brandId))
.orderBy(desc(orders.placedAt))
.limit(1000),
)
@@ -59,9 +59,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
+2 -2
View File
@@ -74,8 +74,8 @@ export async function GET(req: NextRequest) {
// Collect all logs across brands
let allLogs: LogEntry[] = [];
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
const allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
const allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
for (const brandId of brandIds) {
const [workers, settings, logs] = await Promise.all([
+4 -4
View File
@@ -17,7 +17,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -25,16 +25,16 @@ export async function GET() {
const { data: stops } = await supabase
.from("stops")
.select("*")
.select("city, state, date, time, location")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+13
View File
@@ -5,6 +5,7 @@ import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const createCampaignSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -83,6 +90,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+17 -4
View File
@@ -7,6 +7,7 @@ import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const getProductsSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -60,10 +67,10 @@ export async function GET(req: NextRequest) {
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
// because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
// schema's app.current_brand_id GUC, so we filter explicitly.
const whereParts = [];
if (validation.data.brand_id) {
whereParts.push(eq(products.tenantId, validation.data.brand_id));
whereParts.push(eq(products.brandId, validation.data.brand_id));
}
if (validation.data.is_active !== undefined) {
whereParts.push(eq(products.active, validation.data.is_active));
@@ -82,7 +89,7 @@ export async function GET(req: NextRequest) {
void validation.data.category;
// Public read across all tenants (this is a public catalog API, not
// an admin endpoint), so use `withDb` rather than `withTenant`.
// an admin endpoint), so use `withDb` rather than `withBrand`.
const rows = await withDb(async (db) =>
db
.select()
@@ -107,6 +114,12 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -139,7 +152,7 @@ export async function POST(req: NextRequest) {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
brandId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
+13
View File
@@ -5,6 +5,7 @@ import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const createReferralSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -69,6 +76,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+7
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const reportFiltersSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
+13
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -35,6 +36,12 @@ const createWaterLogSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -77,6 +84,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+13 -28
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadObject, BUCKETS } from "@/lib/storage";
export async function POST(request: Request) {
try {
@@ -15,8 +15,8 @@ export async function POST(request: Request) {
const file = formData.get("file") as File | null;
const bucket = formData.get("bucket") as string | null;
if (!file || !bucket) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
if (!file) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
@@ -27,34 +27,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabasePat = process.env.SUPABASE_PAT!;
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const key = `water-logs/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
// bucket param is accepted for backwards compat but ignored — always use WATER_LOGS
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadObject({
bucket: BUCKETS.WATER_LOGS,
key,
body: buffer,
contentType: file.type,
});
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
{
method: "POST",
headers: {
...svcHeaders(supabasePat),
"Content-Type": file.type,
"x-upsert": "true",
},
body: uint8,
}
);
if (!uploadRes.ok) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
return NextResponse.json({ url: publicUrl });
return NextResponse.json({ url });
} catch (err) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
@@ -2,6 +2,59 @@ import { NextResponse } from "next/server";
import crypto from "crypto";
import { pool } from "@/lib/db";
/**
* Validates a webhook URL to prevent SSRF attacks.
* Blocks:
* - Non-HTTPS URLs
* - localhost and 127.0.0.1
* - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
* - Link-local IPv6 addresses (fc00::/7, fe80::/10)
* - AWS metadata endpoint (169.254.169.254)
*/
function validateWebhookUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow HTTPS URLs
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost and loopback
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return false;
}
// Block private IP ranges using regex patterns
const blockedPatterns = [
// 10.0.0.0/8 - Class A private network
/^10\./,
// 172.16.0.0/12 - Class B private network (172.16.x.x - 172.31.x.x)
/^172\.(1[6-9]|2[0-9]|3[01])\./,
// 192.168.0.0/16 - Class C private network
/^192\.168\./,
// AWS metadata endpoint
/^169\.254\.169\.254$/,
// Link-local IPv6 (fc00::/7 and fe80::/10)
/^fc00:/i,
/^fe80:/i,
];
for (const pattern of blockedPatterns) {
if (pattern.test(hostname)) {
return false;
}
}
return true;
} catch {
// Invalid URL format
return false;
}
}
// POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
@@ -33,6 +86,19 @@ export async function POST() {
let dispatched = 0;
for (const webhook of pending) {
// Validate URL to prevent SSRF attacks
if (!validateWebhookUrl(webhook.url)) {
console.error("[SSRF_BLOCKED]", {
webhookId: webhook.id,
brandId: webhook.brand_id,
url: webhook.url,
eventType: webhook.event_type,
timestamp: new Date().toISOString(),
});
await markFailed(webhook.id, "SSRF attempt blocked: invalid webhook URL");
continue;
}
const payload = webhook.payload ?? {};
const payloadString = JSON.stringify(payload);
+30 -16
View File
@@ -2,8 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
import Link from "next/link";
export default function ChangePasswordForm({ userId }: { userId: string }) {
const router = useRouter();
@@ -26,22 +25,28 @@ export default function ChangePasswordForm({ userId }: { userId: string }) {
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? data.error ?? "Failed to update password.");
setLoading(false);
return;
}
router.push("/admin");
router.refresh();
} catch {
setError("An unexpected error occurred. Please try again.");
setLoading(false);
}
logUserActivity({
user_id: userId,
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
@@ -113,6 +118,15 @@ export default function ChangePasswordForm({ userId }: { userId: string }) {
{loading ? "Updating..." : "Update Password"}
</button>
</form>
<div className="mt-4 border-t border-zinc-800 pt-4">
<Link
href="/logout"
className="block text-center text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Sign out instead
</Link>
</div>
</div>
</div>
</main>
+21 -17
View File
@@ -3,8 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { updatePasswordAction } from "@/actions/admin/password";
import { logUserActivity } from "@/actions/admin/audit";
import { getCurrentUserId } from "@/actions/admin/password";
export default function ChangePasswordPage() {
const router = useRouter();
@@ -27,23 +26,28 @@ export default function ChangePasswordPage() {
}
setLoading(true);
const result = await updatePasswordAction(password);
setLoading(false);
if (result.error) {
setError(result.error);
return;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok || data.error) {
setError(data.message ?? data.error ?? "Failed to update password.");
setLoading(false);
return;
}
router.push("/admin");
router.refresh();
} catch {
setError("An unexpected error occurred. Please try again.");
setLoading(false);
}
// Audit log (best-effort — the password change itself was the source of truth)
logUserActivity({
user_id: result.userId ?? "unknown",
activity_type: "password_change",
details: {},
}).catch(() => {});
router.push("/admin");
router.refresh();
}
return (
+13 -6
View File
@@ -37,10 +37,17 @@ export default function CheckoutClient() {
const [hostedError, setHostedError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null);
if (typeof window !== "undefined" && !idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
const [idempotencyKey, setIdempotencyKey] = useState<string>("");
useEffect(() => {
// Initialize idempotency key in useEffect, not during render
const init = async () => {
if (typeof window !== "undefined" && !idempotencyKey) {
setIdempotencyKey(crypto.randomUUID());
}
};
init();
}, [idempotencyKey]);
useEffect(() => {
if (!cartBrandId) return;
@@ -112,7 +119,7 @@ export default function CheckoutClient() {
shippingAddress: hasShipItems
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
: undefined,
idempotencyKey: idempotencyKeyRef.current,
idempotencyKey: idempotencyKey,
})
);
}
@@ -408,7 +415,7 @@ export default function CheckoutClient() {
shippingState={shippingState}
shippingPostal={shippingPostal}
shippingCity={shippingCity}
checkoutSessionKey={idempotencyKeyRef.current ?? undefined}
checkoutSessionKey={idempotencyKey || undefined}
onHostedCheckout={() => void handleHostedCheckout()}
/>
</div>
+9 -8
View File
@@ -74,14 +74,14 @@ function SuccessContent() {
// embedded Stripe Elements (?payment_intent=...). Both paths store the
// same payload in `pending_checkout` before payment is initiated.
useEffect(() => {
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
const init = async () => {
if (!sessionId && !paymentIntentId) return;
if (paymentIntentId && redirectStatus && redirectStatus !== "succeeded") {
setError("Payment was not completed. Please try again.");
setCreating(false);
return;
}
(async () => {
const pendingStr = sessionStorage.getItem("pending_checkout");
if (!pendingStr) {
setError("No pending order found. Please start checkout again.");
@@ -133,7 +133,8 @@ function SuccessContent() {
setError("Failed to create order. Please contact support.");
setCreating(false);
}
})();
};
init();
}, [sessionId, paymentIntentId, redirectStatus]);
if (!order || !orderIdParam) {

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