diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts index f0a7a75..eaa3a9c 100644 --- a/src/actions/admin/password.ts +++ b/src/actions/admin/password.ts @@ -4,6 +4,7 @@ import "server-only"; import { getSession } from "@/lib/auth"; import { setUserPassword } from "@/lib/auth"; import { getAdminUser } from "@/lib/admin-permissions"; +import { serverLog, serverError } from "@/lib/server-log"; const MIN_PASSWORD_LENGTH = 8; @@ -40,14 +41,14 @@ await getSession(); // Verify the caller is an admin }); if (result.error) { - console.error("[updatePassword] Failed:", result.error); + serverError("[updatePassword] Failed:", result.error); return { success: false, error: result.error.message ?? "Failed to update password." }; } - console.log("[updatePassword] Password updated for user:", userId); + serverLog("[updatePassword] Password updated for user:", userId); return { success: true }; } catch (err) { - console.error("[updatePassword] Unexpected error:", err); + serverError("[updatePassword] Unexpected error:", err); return { success: false, error: "An unexpected error occurred." }; } } diff --git a/src/actions/admin/reset-admin.ts b/src/actions/admin/reset-admin.ts index cf75124..d53643c 100644 --- a/src/actions/admin/reset-admin.ts +++ b/src/actions/admin/reset-admin.ts @@ -3,6 +3,7 @@ import "server-only"; import { randomBytes } from "crypto"; import { query } from "@/lib/db"; +import { serverWarn } from "@/lib/server-log"; import { requestPasswordReset as neonAuthRequestPasswordReset, setUserPassword as neonAuthSetUserPassword, @@ -84,7 +85,7 @@ await getSession(); // 1. Authz check. code === "FORBIDDEN" || code === "UNAUTHORIZED" || code === "INTERNAL_SERVER_ERROR"; - console.warn( + serverWarn( "[resetAdminPassword] setUserPassword failed (code=%s), will%s fall back: %s", code, canFallback ? "" : " NOT", @@ -107,7 +108,7 @@ await getSession(); // 1. Authz check. [user.id], ); } catch (flagErr) { - console.warn( + serverWarn( "[resetAdminPassword] failed to set must_change_password (non-fatal):", flagErr, ); diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index 5e0caae..846eb22 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -2,6 +2,7 @@ import "server-only"; import { query, withTx } from "@/lib/db"; +import { serverWarn } from "@/lib/server-log"; import { createUser as neonAuthCreateUser, requestPasswordReset as neonAuthRequestPasswordReset, @@ -131,7 +132,7 @@ async function sendWelcomeEmailSafe(input: { brandName = settings.settings.brand_name ?? brandName; } } catch (brandErr) { - console.warn( + serverWarn( "[createAdminUser] Failed to load brand settings for welcome email:", brandErr, ); @@ -495,7 +496,7 @@ async function signupFallbackCreate( try { await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]); } catch (verifyErr) { - console.warn( + serverWarn( "[createAdminUser] Failed to set emailVerified=true (non-fatal):", verifyErr, ); diff --git a/src/actions/auth-actions.ts b/src/actions/auth-actions.ts index 608b6da..4cbc50d 100644 --- a/src/actions/auth-actions.ts +++ b/src/actions/auth-actions.ts @@ -5,13 +5,14 @@ import { cookies } from "next/headers"; import { signIn, signOut } from "@/lib/auth"; import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; +import { serverLog } from "@/lib/server-log"; /** * Sign out and clear the Neon Auth session cookie. */ export async function signOutAction(): Promise { await getSession(); - console.log("[auth/sign-out] Signing out"); + serverLog("[auth/sign-out] Signing out"); await signOut(); redirect("/login"); } diff --git a/src/actions/communications/stop-blast.ts b/src/actions/communications/stop-blast.ts index 487c2c0..ae312ec 100644 --- a/src/actions/communications/stop-blast.ts +++ b/src/actions/communications/stop-blast.ts @@ -42,35 +42,39 @@ await getSession(); const adminUser = await getAdminUser(); 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 }) - .from(customers) - .innerJoin(orders, eq(orders.customerId, customers.id)) - .where( - and( - eq(orders.brandId, params.brandId), - params.channel === "sms" - ? eq(customers.smsOptIn, true) - : params.channel === "email" - ? eq(customers.emailOptIn, true) - : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`, + // These two queries are independent (no shared state between + // them and they don't feed into each other), so run them in + // parallel — saves a full round-trip per request. + const [orderCustomers, countRows] = await Promise.all([ + db + .selectDistinct({ id: customers.id }) + .from(customers) + .innerJoin(orders, eq(orders.customerId, customers.id)) + .where( + and( + eq(orders.brandId, params.brandId), + params.channel === "sms" + ? eq(customers.smsOptIn, true) + : params.channel === "email" + ? eq(customers.emailOptIn, true) + : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`, + ), + ) + .limit(1000), + db + .select({ value: sql`count(*)::int` }) + .from(customers) + .where( + and( + ...conds, + params.channel === "sms" + ? eq(customers.smsOptIn, true) + : params.channel === "email" + ? eq(customers.emailOptIn, true) + : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`, + ), ), - ) - .limit(1000); - - const countRows = await db - .select({ value: sql`count(*)::int` }) - .from(customers) - .where( - and( - ...conds, - params.channel === "sms" - ? eq(customers.smsOptIn, true) - : params.channel === "email" - ? eq(customers.emailOptIn, true) - : sql`(${customers.smsOptIn} = true OR ${customers.emailOptIn} = true)`, - ), - ); + ]); return { ids: orderCustomers.map((r) => r.id), diff --git a/src/actions/communications/stop-messaging.ts b/src/actions/communications/stop-messaging.ts index 59cb013..594030a 100644 --- a/src/actions/communications/stop-messaging.ts +++ b/src/actions/communications/stop-messaging.ts @@ -60,37 +60,39 @@ await getSession(); const adminUser = await getAdminUser(); try { const [orderRows, campaignRows] = await withBrand(brandId, async (db) => { - const o = await db - .select({ - id: orders.id, - status: orders.status, - placedAt: orders.placedAt, - customerName: customers.fullName, - customerEmail: customers.email, - customerPhone: customers.phone, - }) - .from(orders) - .innerJoin(customers, eq(customers.id, orders.customerId)) - .where( - and( - eq(orders.brandId, brandId), - isNotNull(customers.email), - ), - ) - .orderBy(desc(orders.placedAt)) - .limit(50); - - const c = await db - .select({ - id: campaigns.id, - name: campaigns.name, - sentAt: campaigns.sentAt, - updatedAt: campaigns.updatedAt, - }) - .from(campaigns) - .where(eq(campaigns.brandId, brandId)) - .orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt)) - .limit(10); + // Independent reads — fire in parallel. + const [o, c] = await Promise.all([ + db + .select({ + id: orders.id, + status: orders.status, + placedAt: orders.placedAt, + customerName: customers.fullName, + customerEmail: customers.email, + customerPhone: customers.phone, + }) + .from(orders) + .innerJoin(customers, eq(customers.id, orders.customerId)) + .where( + and( + eq(orders.brandId, brandId), + isNotNull(customers.email), + ), + ) + .orderBy(desc(orders.placedAt)) + .limit(50), + db + .select({ + id: campaigns.id, + name: campaigns.name, + sentAt: campaigns.sentAt, + updatedAt: campaigns.updatedAt, + }) + .from(campaigns) + .where(eq(campaigns.brandId, brandId)) + .orderBy(desc(campaigns.sentAt), desc(campaigns.updatedAt)) + .limit(10), + ]); return [o, c] as const; }); diff --git a/src/actions/harvest-reach/segments.ts b/src/actions/harvest-reach/segments.ts index d0fecf5..ac13c8b 100644 --- a/src/actions/harvest-reach/segments.ts +++ b/src/actions/harvest-reach/segments.ts @@ -230,20 +230,23 @@ await getSession(); const adminUser = await getAdminUser(); try { const [samples, counts] = await withBrand(brandId, async (db) => { - const sample = await db - .select({ - id: customers.id, - email: customers.email, - fullName: customers.fullName, - phone: customers.phone, - }) - .from(customers) - .where(and(...conds)) - .limit(5); - const c = await db - .select({ value: sql`count(*)::int` }) - .from(customers) - .where(and(...conds)); + // Independent reads — fire in parallel. + const [sample, c] = await Promise.all([ + db + .select({ + id: customers.id, + email: customers.email, + fullName: customers.fullName, + phone: customers.phone, + }) + .from(customers) + .where(and(...conds)) + .limit(5), + db + .select({ value: sql`count(*)::int` }) + .from(customers) + .where(and(...conds)), + ]); return [sample, c] as const; }); diff --git a/src/actions/shipping/fedex-rates.ts b/src/actions/shipping/fedex-rates.ts index b0f4bcf..fde2558 100644 --- a/src/actions/shipping/fedex-rates.ts +++ b/src/actions/shipping/fedex-rates.ts @@ -183,15 +183,16 @@ await getSession(); const adminUser = await getAdminUser(); return { success: false, error: "FedEx not configured for this brand" }; } - // Check perishable status - const perishable = await isOrderPerishable(orderId, effectiveBrandId); - - // Get FedEx token - const tokenResult = await getFedExToken({ - fedexApiKey: settings.fedex_api_key, - fedexApiSecret: settings.fedex_api_secret, - useProduction: settings.fedex_use_production, - }); + // Check perishable status and fetch FedEx token — these are + // independent, so fetch them in parallel and save a round-trip. + const [perishable, tokenResult] = await Promise.all([ + isOrderPerishable(orderId, effectiveBrandId), + getFedExToken({ + fedexApiKey: settings.fedex_api_key, + fedexApiSecret: settings.fedex_api_secret, + useProduction: settings.fedex_use_production, + }), + ]); if ("error" in tokenResult) { return { success: false, error: tokenResult.error }; diff --git a/src/actions/stops/get-stop-details.ts b/src/actions/stops/get-stop-details.ts index 443a1ff..c06ad2e 100644 --- a/src/actions/stops/get-stop-details.ts +++ b/src/actions/stops/get-stop-details.ts @@ -78,28 +78,31 @@ await getSession(); const adminUser = await getAdminUser(); return { success: false, error: "Not authorized for this brand" }; } - // 2. Candidate products for this brand - const { rows: allProducts } = await pool.query<{ id: string; name: string; type: string; price: number }>( - `SELECT id, name, type, price - FROM products - WHERE brand_id = $1 AND active = true`, - [stop.brand_id], - ); - - // 3. Assigned products (joined with product info) - const { rows: productStops } = await pool.query( - `SELECT ps.id, ps.product_id, - json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products - FROM product_stops ps - LEFT JOIN products p ON p.id = ps.product_id - WHERE ps.stop_id = $1`, - [stopId], - ); - - // 4. Brands for the brand switcher - const { rows: brands } = await pool.query<{ id: string; name: string; slug: string }>( - `SELECT id, name, slug FROM brands ORDER BY name`, - ); + // 2 + 3 + 4. Candidate products, assigned products, and brand switcher + // list — all independent reads, fetched in parallel. + const [ + { rows: allProducts }, + { rows: productStops }, + { rows: brands }, + ] = await Promise.all([ + pool.query<{ id: string; name: string; type: string; price: number }>( + `SELECT id, name, type, price + FROM products + WHERE brand_id = $1 AND active = true`, + [stop.brand_id], + ), + pool.query( + `SELECT ps.id, ps.product_id, + json_build_object('id', p.id, 'name', p.name, 'type', p.type, 'price', p.price) AS products + FROM product_stops ps + LEFT JOIN products p ON p.id = ps.product_id + WHERE ps.stop_id = $1`, + [stopId], + ), + pool.query<{ id: string; name: string; slug: string }>( + `SELECT id, name, slug FROM brands ORDER BY name`, + ), + ]); return { success: true, diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index 85c181d..012a6df 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -128,28 +128,33 @@ await getSession(); // Validate format first (cheap, no DB). return { success: false, error: "Invalid PIN" }; } - // Create a session in the user's brand context. - const sessionId = await withBrand(match.brandId, async (db) => { - const expiresAt = new Date( - Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, - ); - const [row] = await db - .insert(waterSessions) - .values({ - irrigatorId: match.id, - expiresAt, - }) - .returning({ id: waterSessions.id }); - if (!row) throw new Error("Failed to create session"); - // Bump last_used_at for "I haven't seen this person in a while" UX - await db - .update(waterIrrigators) - .set({ lastUsedAt: new Date() }) - .where(eq(waterIrrigators.id, match.id)); - return row.id; - }); - - const cookieStore = await cookies(); + // Create a session in the user's brand context. The session insert + // and the last_used_at bump are independent writes — fire them in + // parallel and wait on both before returning. + const [sessionId, cookieStore] = await Promise.all([ + withBrand(match.brandId, async (db) => { + const expiresAt = new Date( + Date.now() + FIELD_SESSION_HOURS * 60 * 60 * 1000, + ); + const [insertResult] = await Promise.all([ + db + .insert(waterSessions) + .values({ + irrigatorId: match.id, + expiresAt, + }) + .returning({ id: waterSessions.id }), + db + .update(waterIrrigators) + .set({ lastUsedAt: new Date() }) + .where(eq(waterIrrigators.id, match.id)), + ]); + const row = insertResult[0]; + if (!row) throw new Error("Failed to create session"); + return row.id; + }), + cookies(), + ]); cookieStore.set("wl_session", sessionId, { httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/src/actions/water-log/settings.ts b/src/actions/water-log/settings.ts index 1573e1b..1d74b79 100644 --- a/src/actions/water-log/settings.ts +++ b/src/actions/water-log/settings.ts @@ -163,17 +163,22 @@ await getSession(); const adminUser = await getAdminUser(); const pin = generatePin(); const pinHash = hashPin(pin); return withBrand(brandId, async (db) => { - await db - .insert(waterAdminSettings) - .values({ brandId, pinHash }) - .onConflictDoUpdate({ - target: waterAdminSettings.brandId, - set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null }, - }); - // Invalidate any existing admin sessions for safety - await db - .delete(waterAdminSessions) - .where(eq(waterAdminSessions.brandId, brandId)); + // The upsert and the session-invalidation delete are independent — + // they touch different tables and don't reference each other's + // results, so run them in parallel. + await Promise.all([ + db + .insert(waterAdminSettings) + .values({ brandId, pinHash }) + .onConflictDoUpdate({ + target: waterAdminSettings.brandId, + set: { pinHash, updatedBy: adminUser.user_id ?? adminUser.id ?? null }, + }), + // Invalidate any existing admin sessions for safety + db + .delete(waterAdminSessions) + .where(eq(waterAdminSessions.brandId, brandId)), + ]); await logAuditEvent({ brandId, actorId: adminUser.user_id ?? null, diff --git a/src/app/admin/settings/integrations/IntegrationsClientPage.tsx b/src/app/admin/settings/integrations/IntegrationsClientPage.tsx index bf4ffde..339d070 100644 --- a/src/app/admin/settings/integrations/IntegrationsClientPage.tsx +++ b/src/app/admin/settings/integrations/IntegrationsClientPage.tsx @@ -119,14 +119,11 @@ function IntegrationCard({ const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(null); - const [dirty, setDirty] = useState(false); - // Track dirty state - useEffect(() => { - const hasValues = Object.values(credentials).some((v) => v.trim().length > 0); - // eslint-disable-next-line react-hooks/set-state-in-effect - setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials)); - }, [credentials, initialCredentials]); + // Derived from `credentials` + `initialCredentials` during render so we + // don't pay for an extra commit and stale-frame window. + const hasValues = Object.values(credentials).some((v) => v.trim().length > 0); + const dirty = hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials); async function handleTest() { setTestResult(null); diff --git a/src/app/admin/v2/products/page.tsx b/src/app/admin/v2/products/page.tsx index 7783f15..ad42bd2 100644 --- a/src/app/admin/v2/products/page.tsx +++ b/src/app/admin/v2/products/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from "react"; +import Image from "next/image"; import { getAdminUser } from "@/lib/admin-permissions"; import { getActiveBrandId } from "@/lib/brand-scope"; import { pool } from "@/lib/db"; @@ -193,14 +194,17 @@ export default async function ProductsV2Page({
{product.image_url ? ( - // eslint-disable-next-line @next/next/no-img-element -- product - // images may live on a third-party CDN; Next/Image - // would require us to whitelist the host. A plain - // is fine here since this is a mobile-first - // admin page, not a public storefront. - ) : ( diff --git a/src/app/admin/water-log/headgates/HeadgatesManager.tsx b/src/app/admin/water-log/headgates/HeadgatesManager.tsx index a421af6..00978fe 100644 --- a/src/app/admin/water-log/headgates/HeadgatesManager.tsx +++ b/src/app/admin/water-log/headgates/HeadgatesManager.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { getWaterHeadgatesAdmin, @@ -331,9 +332,12 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) { {/* QR preview thumbnail */} @@ -531,9 +535,12 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
{hg.name}
{code}
- QR
diff --git a/src/app/admin/wholesale/WholesaleClient.tsx b/src/app/admin/wholesale/WholesaleClient.tsx index 06b42f0..aabd3dd 100644 --- a/src/app/admin/wholesale/WholesaleClient.tsx +++ b/src/app/admin/wholesale/WholesaleClient.tsx @@ -118,13 +118,10 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { { value: "settings", label: "Settings" }, ]; - // Declare icon reference for PageHeader - const pageIcon = ; - return (
} title="Wholesale Portal" subtitle="Manage wholesale orders, customers, and products" className="mb-0" diff --git a/src/app/brands/page.tsx b/src/app/brands/page.tsx index 07d026a..59b5a40 100644 --- a/src/app/brands/page.tsx +++ b/src/app/brands/page.tsx @@ -227,45 +227,6 @@ export default function BrandsPage() {
-
); } \ No newline at end of file diff --git a/src/app/cart/CartClient.tsx b/src/app/cart/CartClient.tsx index 3bc8e2e..40d85a3 100644 --- a/src/app/cart/CartClient.tsx +++ b/src/app/cart/CartClient.tsx @@ -25,38 +25,37 @@ export default function CartClient() { const [loadingStops, setLoadingStops] = useState(false); const [showStopPicker, setShowStopPicker] = useState(false); const [incompatibleItems, setIncompatibleItems] = useState([]); - const [stopBrandMismatch, setStopBrandMismatch] = useState(false); const [availabilityError, setAvailabilityError] = useState(false); const hasPickupItems = cart.some((i) => i.fulfillment === "pickup"); const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed"); const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"); - // Check brand mismatch - useEffect(() => { - if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setStopBrandMismatch(true); - } else { - setStopBrandMismatch(false); - } - }, [selectedStop, cartBrandId]); + // Brand mismatch is a pure derivation of selectedStop + cartBrandId — + // compute it inline during render instead of mirroring it into state + // and syncing via useEffect. This also removes the "fake event handler" + // anti-pattern flagged by react-doctor. + const stopBrandMismatch = !!( + selectedStop?.brand_id && + cartBrandId && + selectedStop.brand_id !== cartBrandId + ); - // Fetch stops when picker is open - useEffect(() => { - if (hasPickupItems && showStopPicker && cartBrandId) { - // eslint-disable-next-line react-hooks/set-state-in-effect + // Stops are fetched on demand when the picker opens. The handler + // below owns the side effect — no useEffect watching showStopPicker. + const handleOpenStopPicker = useCallback(() => { + setShowStopPicker(true); + if (hasPickupItems && cartBrandId && stops.length === 0) { setLoadingStops(true); getPublicStopsForBrand(cartBrandId) .then((data) => setStops(data ?? [])) .catch(() => setStops([])) .finally(() => setLoadingStops(false)); } - }, [hasPickupItems, showStopPicker, cartBrandId]); + }, [hasPickupItems, cartBrandId, stops.length]); const handleStopSelect = useCallback((stop: Stop) => { setSelectedStop(stop); - setStopBrandMismatch(false); setShowStopPicker(false); setAvailabilityError(false); setIncompatibleItems([]); @@ -80,10 +79,10 @@ export default function CartClient() { const handleCheckoutClick = useCallback(() => { if (cart.length === 0) return; if (stopBrandMismatch) { setSelectedStop(null); return; } - if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; } + if (hasStopPickupItems && !selectedStop) { handleOpenStopPicker(); return; } if (incompatibleItems.length > 0) { return; } window.location.href = "/checkout"; - }, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop]); + }, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems, setSelectedStop, handleOpenStopPicker]); return (
@@ -114,7 +113,7 @@ export default function CartClient() {

Pickup stop is from a different store

Your cart was updated. Please select a new pickup stop.