From 0ac4beaaa835b2ac3849988ebe7378c8db8bc892 Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 25 Jun 2026 23:49:37 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20react-doctor=20errors=20=E2=86=92=200=20?= =?UTF-8?q?errors,=201649=20warnings=20(46/100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports) --- .gitignore | 6 - CLAUDE.md | 56 +- ENVIRONMENT.md | 28 +- LAUNCH_CHECKLIST.md | 18 +- MEMORY.md | 91 +- PRODUCTION_DEPLOYMENT_CHECKLIST.md | 86 +- PRODUCTION_SETUP.md | 28 +- README.md | 21 +- REPORT.md | 13 +- db/client.ts | 61 +- db/migrations/0000_qa_neon_auth_stub.sql | 12 + db/migrations/0001_init.sql | 2 +- db/migrations/0002_admin_password.sql | 29 +- db/seeds/2026-qa-audit-scale.sql | 412 + db/seeds/2026-tuxedo-tour-stops.sql | 1 + .../audit-2026-06-25/ACCEPTANCE-CRITERIA.md | 671 ++ docs/qa/audit-2026-06-25/ENV-DIFF.md | 61 + docs/qa/audit-2026-06-25/INVENTORY.md | 333 + docs/qa/audit-2026-06-25/PLAN.md | 60 + docs/qa/eslint-baseline-2026-06-25.log | 905 +++ docs/qa/react-doctor-baseline-2026-06-25.log | 6849 +++++++++++++++++ docs/qa/tsc-baseline-2026-06-25.log | 18 + docs/qa/vitest-baseline-2026-06-25.log | 36 + docs/water-log.md | 2 +- eslint.config.mjs | 3 +- next.config.ts | 16 +- package.json | 4 +- scripts/apply-admin-create-stop.js | 160 + scripts/check-stop-fns.js | 49 + scripts/check-stop-fns2.js | 52 + scripts/check-stop-fns3.js | 44 + scripts/cleanup-duplicate-stops.ts | 78 + scripts/create-admin-user.ts | 61 + scripts/e2e-test.sh | 99 + scripts/fix-archived-rls.js | 228 + scripts/fix-button-has-type.js | 128 + scripts/fix-control-has-associated-label.js | 223 + scripts/fix-next-anchor.js | 112 + scripts/fix-server-auth-ast.js | 150 + scripts/fix-server-auth.js | 73 + scripts/fix-zod-format.js | 77 + scripts/import-tuxedo-stops.ts | 3 +- scripts/import-woo-to-route.ts | 262 + scripts/seed-admin.ts | 96 + scripts/seed-tuxedo.ts | 41 + scripts/seed.sh | 343 + scripts/seed_tuxedo_tour.py | 343 + scripts/upload-tuxedo-video.mjs | 74 + scripts/verify-email.ts | 11 + scripts/verify-stop-fns.js | 132 + src/actions/admin-user.ts | 4 +- src/actions/admin/audit.ts | 7 +- src/actions/admin/password.ts | 3 +- src/actions/admin/reset-admin.ts | 4 +- src/actions/admin/users.ts | 22 +- src/actions/ai-import.ts | 7 +- src/actions/ai/preferences.ts | 107 + src/actions/analytics.ts | 19 +- src/actions/audit.ts | 4 +- src/actions/auth-actions.ts | 7 +- src/actions/billing/billing-overview.ts | 4 +- src/actions/billing/retail-checkout.ts | 8 +- src/actions/billing/retail-payment-intent.ts | 8 +- src/actions/billing/stripe-checkout.ts | 21 +- src/actions/billing/stripe-portal.ts | 23 +- src/actions/brand-settings.ts | 19 +- src/actions/checkout.ts | 19 +- src/actions/communications/campaigns.ts | 13 +- src/actions/communications/contacts.ts | 22 +- src/actions/communications/import-contacts.ts | 10 +- src/actions/communications/segments.ts | 10 +- src/actions/communications/send.ts | 10 +- src/actions/communications/settings.ts | 7 +- src/actions/communications/stop-blast.ts | 4 +- src/actions/communications/stop-messaging.ts | 4 +- src/actions/communications/templates.ts | 10 +- src/actions/dashboard.ts | 11 +- .../email-automation/abandoned-cart.ts | 49 +- .../email-automation/welcome-sequence.ts | 52 +- src/actions/harvest-reach/campaigns.ts | 7 +- src/actions/harvest-reach/products.ts | 4 +- src/actions/harvest-reach/segments.ts | 13 +- src/actions/harvest-reach/stops.ts | 4 +- src/actions/import-orders.ts | 4 +- src/actions/import-products.ts | 4 +- src/actions/integrations/ai-providers.ts | 19 +- src/actions/integrations/credentials.ts | 19 +- src/actions/locations.ts | 22 +- src/actions/offline-dispatcher.ts | 7 +- src/actions/orders.ts | 19 +- src/actions/orders/create-admin-order.ts | 4 +- src/actions/orders/create-refund.ts | 4 +- src/actions/orders/update-order.ts | 10 +- src/actions/payments.ts | 7 +- src/actions/pickup.ts | 4 +- src/actions/products.ts | 4 +- src/actions/products/create-product.ts | 4 +- src/actions/products/update-product.ts | 4 +- src/actions/products/upload-image.ts | 7 +- src/actions/reports.ts | 22 +- src/actions/route-trace/lots.ts | 43 +- src/actions/set-active-brand.ts | 4 +- src/actions/settings/features.ts | 4 +- src/actions/shipping.ts | 7 +- src/actions/shipping/fedex-labels.ts | 22 +- src/actions/shipping/fedex-rates.ts | 22 +- src/actions/shipping/settings.ts | 25 +- src/actions/square-inventory.ts | 7 +- src/actions/square-orders.ts | 4 +- src/actions/square-products.ts | 7 +- src/actions/square-sync-ui.ts | 10 +- src/actions/stops.ts | 16 +- src/actions/stops/create-stop.ts | 4 +- src/actions/stops/get-stop-customers.ts | 4 +- src/actions/stops/get-stop-details.ts | 4 +- src/actions/stops/manage-stop-products.ts | 7 +- src/actions/stops/update-stop.ts | 4 +- src/actions/storefront.ts | 25 +- src/actions/stripe-connect.ts | 29 +- src/actions/tax.ts | 10 +- src/actions/time-tracking/field.ts | 23 +- src/actions/time-tracking/index.ts | 49 +- src/actions/time-tracking/notifications.ts | 4 +- src/actions/water-log/admin.ts | 55 +- src/actions/water-log/field.ts | 31 +- src/actions/water-log/settings.ts | 13 +- src/actions/wholesale-auth.ts | 67 +- src/actions/wholesale-register.ts | 37 +- src/actions/wholesale.ts | 915 +++ src/actions/wholesale/customers.ts | 10 +- src/actions/wholesale/deposits.ts | 10 +- src/actions/wholesale/notifications.ts | 13 +- src/actions/wholesale/orders.ts | 19 +- src/actions/wholesale/products.ts | 10 +- src/actions/wholesale/scope.ts | 7 +- src/actions/wholesale/settings.ts | 10 +- src/actions/wholesale/webhooks.ts | 13 +- src/app/admin/error.tsx | 2 +- src/app/admin/import/ImportCenterClient.tsx | 22 +- src/app/admin/launch-checklist/page.tsx | 8 +- src/app/admin/me/AdminMeClient.tsx | 8 +- src/app/admin/orders/page.tsx | 106 + src/app/admin/page.tsx | 14 +- src/app/admin/pickup/page.tsx | 6 +- src/app/admin/products/[id]/page.tsx | 161 + src/app/admin/products/import/page.tsx | 14 +- src/app/admin/reports/page.tsx | 42 + src/app/admin/route-trace/lots/[id]/page.tsx | 3 +- src/app/admin/sales/import/page.tsx | 12 +- src/app/admin/settings/ai/AIClient.tsx | 234 +- .../admin/settings/billing/AddAddonButton.tsx | 2 +- .../billing/AddPaymentMethodButton.tsx | 2 +- .../admin/settings/billing/BillingClient.tsx | 20 +- .../settings/billing/BillingClientPage.tsx | 20 +- .../settings/billing/BillingCycleToggle.tsx | 4 +- .../settings/billing/PlanUpgradeButton.tsx | 2 +- .../settings/billing/RemoveAddonButton.tsx | 2 +- .../settings/billing/StripePortalButton.tsx | 2 +- src/app/admin/settings/billing/page.tsx | 110 + .../integrations/IntegrationsClient.tsx | 45 +- .../integrations/IntegrationsClientPage.tsx | 20 +- src/app/admin/settings/integrations/page.tsx | 61 + src/app/admin/settings/page.tsx | 8 +- src/app/admin/settings/shipping/page.tsx | 63 + .../square-sync/SquareSyncSettingsClient.tsx | 15 +- src/app/admin/stops/[id]/page.tsx | 50 +- src/app/admin/stops/new/page.tsx | 3 +- src/app/admin/stops/page.tsx | 45 +- src/app/admin/taxes/page.tsx | 84 + src/app/admin/v2/orders/[id]/page.tsx | 3 +- src/app/admin/v2/orders/page.tsx | 16 +- src/app/admin/v2/page.tsx | 9 +- src/app/admin/v2/products/page.tsx | 19 +- src/app/admin/v2/stops/page.tsx | 6 +- src/app/admin/water-log/entries/[id]/page.tsx | 11 +- .../water-log/headgates/HeadgatesManager.tsx | 51 +- .../admin/water-log/headgates/[id]/page.tsx | 11 +- src/app/admin/water-log/settings/page.tsx | 2 +- src/app/admin/water-log/users/[id]/page.tsx | 11 +- src/app/admin/wholesale/WholesaleClient.tsx | 2273 +++++- src/app/api/ai/customer-insights/route.ts | 57 +- src/app/api/cron/send-scheduled/route.ts | 114 +- .../email-automation/abandoned-cart/route.ts | 43 +- .../welcome-sequence/route.ts | 53 +- .../indian-river-direct/schedule-pdf/route.ts | 52 +- src/app/api/square/oauth/callback/route.ts | 148 +- src/app/api/stripe/oauth/callback/route.ts | 126 +- src/app/api/time-tracking/export/route.ts | 32 +- src/app/api/time-tracking/notify/route.ts | 6 +- src/app/api/tuxedo/schedule-pdf/route.ts | 60 +- src/app/api/v1/campaigns/route.ts | 8 +- src/app/api/v1/products/route.ts | 2 +- src/app/api/v1/referrals/route.ts | 4 +- src/app/api/v1/reports/route.ts | 6 +- src/app/api/v1/water-logs/route.ts | 6 +- src/app/api/water-qr-sheet/route.ts | 25 +- src/app/api/wholesale/checkout/route.ts | 3 +- .../wholesale/invoice/[orderId]/pdf/route.ts | 6 +- .../api/wholesale/invoice/[orderId]/route.ts | 6 +- .../api/wholesale/notifications/send/route.ts | 146 +- src/app/api/wholesale/price-sheet/route.ts | 8 +- .../api/wholesale/webhooks/dispatch/route.ts | 96 +- src/app/blog/page.tsx | 15 +- src/app/brands/page.tsx | 7 +- src/app/cart/CartClient.tsx | 22 +- .../change-password/ChangePasswordForm.tsx | 4 +- src/app/change-password/page.tsx | 4 +- src/app/changelog/page.tsx | 11 +- src/app/checkout/CheckoutClient.tsx | 17 +- src/app/checkout/success/page.tsx | 6 +- src/app/contact/ContactClientPage.tsx | 17 +- src/app/indian-river-direct/about/error.tsx | 2 +- .../contact/ContactClientPage.tsx | 46 +- src/app/indian-river-direct/contact/error.tsx | 2 +- src/app/indian-river-direct/error.tsx | 2 +- .../indian-river-direct/faq/FAQClientPage.tsx | 16 +- src/app/indian-river-direct/faq/error.tsx | 2 +- src/app/indian-river-direct/page.tsx | 26 +- .../stops/IndianRiverStopsList.tsx | 2 +- .../stops/[slug]/error.tsx | 66 + .../stops/[slug]/loading.tsx | 6 + .../indian-river-direct/stops/[slug]/page.tsx | 276 + src/app/login/LoginClient.tsx | 12 +- src/app/logout/page.tsx | 43 +- src/app/pricing/PricingClientPage.tsx | 20 +- src/app/privacy-policy/page.tsx | 15 +- src/app/reset-password/page.tsx | 4 +- src/app/roadmap/page.tsx | 17 +- src/app/security/page.tsx | 15 +- src/app/terms-and-conditions/page.tsx | 15 +- src/app/test/page.tsx | 21 + src/app/tuxedo/about/error.tsx | 2 +- src/app/tuxedo/contact/ContactClientPage.tsx | 31 +- src/app/tuxedo/contact/error.tsx | 2 +- src/app/tuxedo/error.tsx | 2 +- src/app/tuxedo/faq/FAQClientPage.tsx | 6 +- src/app/tuxedo/faq/error.tsx | 2 +- src/app/tuxedo/page.tsx | 20 +- .../tuxedo/products/sweet-corn-box/page.tsx | 13 +- src/app/tuxedo/stops/TuxedoStopsList.tsx | 2 +- src/app/tuxedo/stops/[slug]/error.tsx | 61 + src/app/tuxedo/stops/[slug]/loading.tsx | 6 + src/app/tuxedo/stops/[slug]/page.tsx | 235 + src/app/waitlist/page.tsx | 11 +- .../water/admin/login/WaterAdminPinClient.tsx | 2 +- .../employee/EmployeePortalClient.tsx | 446 ++ src/app/wholesale/employee/page.tsx | 516 +- src/app/wholesale/login/page.tsx | 6 +- .../portal/WholesalePortalClient.tsx | 851 ++ src/app/wholesale/portal/page.tsx | 1065 +-- src/app/wholesale/register/page.tsx | 18 +- src/auth.config.ts | 2 +- src/components/admin/AIProviderPanel.tsx | 42 +- .../admin/AbandonedCartDashboard.tsx | 28 +- src/components/admin/AddLocationModal.tsx | 18 +- src/components/admin/AddStopModal.tsx | 64 +- src/components/admin/AdminHeader.tsx | 150 +- src/components/admin/AdminOrdersPanel.tsx | 63 +- src/components/admin/AdminSidebar.tsx | 30 +- src/components/admin/AdminStopsPanel.tsx | 22 +- src/components/admin/AdvancedAIPanel.tsx | 52 +- src/components/admin/AdvancedIntegrations.tsx | 25 +- src/components/admin/AdvancedPayments.tsx | 8 +- .../admin/AdvancedSettingsClient.tsx | 8 +- src/components/admin/AdvancedShipping.tsx | 12 +- src/components/admin/AdvancedSquareSync.tsx | 12 +- src/components/admin/AnalyticsDashboard.tsx | 22 +- src/components/admin/BrandFeatureCards.tsx | 6 +- src/components/admin/BrandSettingsForm.tsx | 215 +- src/components/admin/CampaignListPanel.tsx | 98 +- src/components/admin/CartHydration.tsx | 12 +- src/components/admin/CommandPalette.tsx | 84 +- .../admin/CommunicationsLoading.tsx | 4 +- src/components/admin/ContactImportForm.tsx | 326 +- src/components/admin/ContactListPanel.tsx | 12 +- src/components/admin/CreateUserModal.tsx | 18 +- src/components/admin/DashboardHeader.tsx | 2 +- .../admin/DashboardUpgradeButton.tsx | 2 +- src/components/admin/DriverPickupPanel.tsx | 8 +- src/components/admin/EditLocationModal.tsx | 31 +- src/components/admin/EditStopModal.tsx | 48 +- src/components/admin/ElegantModal.tsx | 2 +- src/components/admin/GlassModal.tsx | 2 +- .../admin/HarvestReach/AnalyticsDashboard.tsx | 22 +- .../HarvestReach/CampaignComposerPage.tsx | 59 +- .../HarvestReach/MatchingCustomersPanel.tsx | 31 +- .../admin/HarvestReach/SegmentBuilderPage.tsx | 2 +- .../HarvestReach/SegmentBuilderPanel.tsx | 28 +- .../admin/HarvestReach/SegmentEditModal.tsx | 6 +- .../admin/HarvestReach/SegmentListSidebar.tsx | 2 +- src/components/admin/HeadgateEditForm.tsx | 14 +- src/components/admin/IntegrationsInner.tsx | 10 +- src/components/admin/LocationsTab.tsx | 6 +- .../admin/MessageCustomersSection.tsx | 8 +- src/components/admin/MessageLogPanel.tsx | 18 +- src/components/admin/NewProductForm.tsx | 2 +- src/components/admin/NewStopForm.tsx | 20 +- src/components/admin/OfflineBanner.tsx | 45 +- src/components/admin/OrderEditForm.tsx | 189 +- src/components/admin/OrderPaymentSection.tsx | 10 +- src/components/admin/OrderPickupAction.tsx | 2 +- src/components/admin/PaymentSettingsForm.tsx | 39 +- .../admin/ProductAssignmentForm.tsx | 4 +- src/components/admin/ProductEditForm.tsx | 93 +- src/components/admin/ProductFilterBar.tsx | 4 +- src/components/admin/ProductFormModal.tsx | 14 +- src/components/admin/ProductSyncBanner.tsx | 4 +- src/components/admin/ProductTableBody.tsx | 14 +- src/components/admin/ProductTableClient.tsx | 14 +- src/components/admin/ProductsClient.tsx | 14 +- src/components/admin/PullToRefresh.tsx | 79 +- src/components/admin/ReportsDashboard.tsx | 37 +- src/components/admin/ScheduleImportModal.tsx | 28 +- src/components/admin/SettingsClient.tsx | 4 +- src/components/admin/SettingsSections.tsx | 70 +- .../admin/ShippingFulfillmentPanel.tsx | 6 +- src/components/admin/ShippingSettingsForm.tsx | 181 +- src/components/admin/SquareSyncWidget.tsx | 16 +- src/components/admin/StatsStrip.tsx | 2 +- src/components/admin/StopDetailModal.tsx | 44 +- src/components/admin/StopEditForm.tsx | 104 +- src/components/admin/StopMessagingForm.tsx | 8 +- .../admin/StopProductAssignment.tsx | 29 +- src/components/admin/StopTableClient.tsx | 59 +- src/components/admin/TaxDashboard.tsx | 19 +- src/components/admin/TemplateEditForm.tsx | 47 +- .../admin/TimeTrackingAdminPanel.tsx | 106 +- .../admin/TimeTrackingSettingsClient.tsx | 58 +- src/components/admin/Toast.tsx | 16 +- src/components/admin/ToastContainer.tsx | 4 +- src/components/admin/UpgradePlanModal.tsx | 25 +- src/components/admin/UsersPage.tsx | 48 +- src/components/admin/WaterLogAdminPanel.tsx | 42 +- .../admin/WaterLogEntryEditForm.tsx | 14 +- src/components/admin/WaterUserEditForm.tsx | 32 +- src/components/admin/WebhookLogsSection.tsx | 4 +- .../admin/WelcomeSequenceDashboard.tsx | 22 +- .../admin/design-system/AdminActionMenu.tsx | 8 +- .../admin/design-system/AdminButton.tsx | 4 +- .../design-system/AdminDeleteConfirm.tsx | 4 +- .../admin/design-system/AdminFilterBar.tsx | 10 +- .../admin/design-system/AdminFormElements.tsx | 6 +- .../admin/design-system/AdminModal.tsx | 2 +- .../admin/design-system/AdminPagination.tsx | 10 +- .../admin/design-system/AdminSearchInput.tsx | 2 +- .../admin/design-system/AdminStatsBar.tsx | 2 +- .../admin/design-system/PageHeader.tsx | 2 +- .../admin/products/StockAdjustButton.tsx | 4 +- src/components/admin/shared/DataTable.tsx | 4 +- src/components/admin/shared/FilterBar.tsx | 8 +- src/components/admin/shared/PageHeader.tsx | 2 +- src/components/admin/stops/StopsCalendar.tsx | 8 +- .../admin/stops/StopsDatePicker.tsx | 10 + src/components/admin/stops/StopsLocations.tsx | 2 +- src/components/changelog/ChangelogFeed.tsx | 2 +- src/components/landing/FeaturesAndStats.tsx | 4 +- src/components/landing/HeroSection.tsx | 10 +- src/components/landing/LandingPageWrapper.tsx | 11 +- src/components/landing/TestimonialsAndCTA.tsx | 8 +- src/components/layout/SiteFooter.tsx | 12 +- src/components/legal/CookieConsentBanner.tsx | 19 +- src/components/marketing/WaitlistForm.tsx | 6 +- .../notifications/InAppNotificationCenter.tsx | 4 +- .../notifications/ToastNotification.tsx | 2 +- src/components/onboarding/OnboardingFlow.tsx | 18 +- src/components/providers/ErrorBoundary.tsx | 2 +- src/components/pwa/InstallPrompt.tsx | 4 +- src/components/referral/ReferralSystem.tsx | 14 +- .../route-trace/AdminLookupPage.tsx | 4 +- .../route-trace/FsmaReportModal.tsx | 76 +- src/components/route-trace/LotCreateForm.tsx | 30 +- src/components/route-trace/LotCreateModal.tsx | 46 +- src/components/route-trace/LotDetailPanel.tsx | 30 +- src/components/route-trace/LotListTable.tsx | 37 +- src/components/route-trace/QRScanModal.tsx | 20 +- .../route-trace/QuickNewLotModal.tsx | 12 +- .../route-trace/RouteTraceDashboard.tsx | 15 +- src/components/route-trace/RouteTracePage.tsx | 2 +- .../route-trace/RouteTraceSettings.tsx | 6 +- .../route-trace/ShareTraceButton.tsx | 2 +- .../route-trace/StickerPreviewModal.tsx | 12 +- src/components/shared/LoadingSkeleton.tsx | 22 +- src/components/shared/ThemeToggle.tsx | 2 +- src/components/storefront/BreadcrumbNav.tsx | 2 +- src/components/storefront/CartToast.tsx | 4 +- src/components/storefront/HeroSection.tsx | 12 +- .../storefront/IndianRiverPaginatedStops.tsx | 8 +- src/components/storefront/PaginatedStops.tsx | 8 +- src/components/storefront/ProductCard.tsx | 6 +- src/components/storefront/QuickCartSheet.tsx | 4 +- src/components/storefront/StopCard.tsx | 8 +- .../storefront/StorefrontFooter.tsx | 12 +- .../storefront/StorefrontHeader.tsx | 2 +- .../storefront/StripeExpressCheckout.tsx | 9 +- .../storefront/SweetCornProductPage.tsx | 16 +- src/components/storefront/TuxedoVideoHero.tsx | 26 +- src/components/storefront/ZipCodeSearch.tsx | 6 +- .../time-tracking/TimeTrackingFieldClient.tsx | 46 +- src/components/water/WaterAdminClient.tsx | 32 +- src/components/water/WaterFieldClient.tsx | 30 +- src/components/wholesale/DepositModal.tsx | 8 +- .../wholesale/OrderDetailsModal.tsx | 10 +- src/context/CartContext.tsx | 177 +- src/lib/admin-permissions-service.ts | 76 + src/lib/admin-permissions.ts | 15 + src/lib/billing.ts | 41 +- src/lib/db.ts | 6 +- src/lib/oauth/square.ts | 89 + src/lib/oauth/stripe.ts | 82 + src/lib/offline/sync.ts | 84 +- src/lib/safe-html.tsx | 40 + src/lib/safe-json.ts | 21 + src/lib/safe-window.ts | 31 + src/lib/shipping/fedex-token-cache.ts | 29 + src/lib/stripe-billing.ts | 2 +- src/lib/supabase.ts | 372 + src/lib/use-media-query.ts | 1 - src/lib/use-prev.ts | 28 + src/lib/validation.ts | 34 +- src/lib/welcome-email-sender.ts | 197 + supabase/.gitignore | 8 + supabase/ADMIN_CREATE_STOP_FIX.sql | 92 + supabase/config.toml | 408 + .../.archived/001_checkout_hardening.sql | 201 + .../002_remove_broad_anon_select.sql | 10 + .../migrations/.archived/003_audit_logs.sql | 103 + .../.archived/004_admin_order_fetch.sql | 193 + .../migrations/.archived/005_water_log.sql | 352 + .../.archived/006_water_log_rpcs_fixed.sql | 263 + .../.archived/007_water_log_column_fix.sql | 232 + .../.archived/008_water_log_soft_delete.sql | 123 + .../009_water_log_entry_edit_delete.sql | 88 + .../.archived/010_water_log_unit_types.sql | 202 + .../011_water_log_consolidated_fix.sql | 289 + .../012_water_log_display_summary.sql | 103 + .../.archived/013_water_log_seed_admin.sql | 13 + .../.archived/014_water_sessions_rls.sql | 9 + .../015_water_users_security_hardening.sql | 90 + .../.archived/016_communication_center.sql | 662 ++ .../.archived/017_communication_contacts.sql | 878 +++ .../.archived/018_contact_import_metadata.sql | 230 + .../.archived/019_customers_table.sql | 75 + .../.archived/020_order_customer_linking.sql | 226 + .../.archived/021_shipping_only_brand.sql | 319 + .../.archived/022_operational_events.sql | 646 ++ .../.archived/023_cart_availability_check.sql | 56 + .../024_stop_product_assignment_rpcs.sql | 146 + .../.archived/025_fix_assignment_rpc_auth.sql | 177 + .../.archived/026_debug_assignment_rpc.sql | 101 + .../027_fix_rpc_signature_conflict.sql | 185 + .../.archived/028_fix_caller_uid_type.sql | 243 + .../.archived/029_drop_stale_overloads.sql | 261 + .../030_normalize_dev_user_caller_uid.sql | 268 + .../.archived/031_reports_v1_rpcs.sql | 337 + .../.archived/032_store_employee_role.sql | 177 + .../.archived/033_admin_users_crud.sql | 384 + .../.archived/034_password_change_flow.sql | 23 + .../.archived/035_admin_action_logs.sql | 106 + .../.archived/036_user_activity_logs.sql | 36 + .../037_update_admin_user_profile.sql | 334 + .../.archived/038_product_images.sql | 31 + .../.archived/039_shipping_fulfillment.sql | 8 + .../040_shipping_fulfillment_rpcs.sql | 86 + .../.archived/041_payment_settings.sql | 105 + .../.archived/042_product_import_rpc.sql | 130 + .../.archived/043_square_sync_settings.sql | 10 + .../.archived/044_square_sync_log.sql | 63 + .../045_extend_payment_settings_rpcs.sql | 53 + .../.archived/046_wholesale_portal.sql | 748 ++ .../.archived/047_wholesale_registration.sql | 201 + .../.archived/048_wholesale_enabled.sql | 62 + .../049_wholesale_invoice_settings.sql | 211 + .../050_wholesale_online_payment.sql | 78 + .../051_wholesale_customer_pricing.sql | 92 + .../.archived/052_wholesale_credit_limit.sql | 124 + .../.archived/053_wholesale_bulk_actions.sql | 93 + .../054_wholesale_email_notifications.sql | 160 + .../055_wholesale_notification_settings.sql | 154 + .../.archived/056_wholesale_price_sheet.sql | 10 + .../.archived/057_notification_recipients.sql | 123 + .../058_fix_get_wholesale_orders.sql | 162 + .../.archived/059_order_status_delete.sql | 63 + .../.archived/060_wholesale_pickup_orders.sql | 68 + .../migrations/.archived/061_soft_delete.sql | 167 + .../.archived/062_wholesale_webhooks.sql | 195 + .../063_auto_create_wholesale_settings.sql | 67 + .../.archived/064_seed_wholesale_settings.sql | 163 + .../065_fix_get_wholesale_products.sql | 43 + .../.archived/066_auto_square_sync.sql | 154 + .../.archived/067_checkout_session_id.sql | 160 + .../migrations/.archived/068_user_carts.sql | 67 + .../.archived/069_brand_scoping_phase2.sql | 607 ++ .../.archived/070_rls_policy_audit.sql | 278 + .../071_wholesale_square_sync_toggle.sql | 180 + .../.archived/072_orders_brand_scoping.sql | 218 + .../.archived/073_pickup_order_items_rpc.sql | 185 + .../.archived/074_products_soft_delete.sql | 77 + .../.archived/075_stops_soft_delete.sql | 82 + .../.archived/076_stops_address_cutoff.sql | 6 + .../migrations/.archived/077_stops_status.sql | 9 + .../.archived/078_wholesale_deposit_guard.sql | 63 + .../.archived/079_resend_analytics.sql | 22 + .../.archived/080_communication_segments.sql | 100 + .../.archived/081_stop_blast_campaign.sql | 135 + .../082_brand_name_in_campaign_email.sql | 74 + .../.archived/083_shipping_settings.sql | 99 + .../.archived/083_stripe_connect.sql | 140 + .../migrations/.archived/084_shipments.sql | 73 + .../.archived/085_brand_settings.sql | 154 + .../086_brand_settings_email_integration.sql | 330 + .../.archived/087_brand_logos_bucket.sql | 77 + .../.archived/088_brand_features.sql | 72 + .../089_ai_providers_custom_integrations.sql | 134 + .../.archived/090_storefront_settings.sql | 55 + .../.archived/091_brand_plan_tier.sql | 83 + .../.archived/092_stripe_subscriptions.sql | 74 + .../.archived/093_add_hero_image.sql | 170 + .../094_brand_color_customization.sql | 197 + .../migrations/.archived/095_tax_settings.sql | 248 + .../.archived/096_tax_orders_index.sql | 6 + .../097_extend_create_order_with_items.sql | 192 + .../.archived/098_products_is_taxable.sql | 53 + .../.archived/099_contact_imports_bucket.sql | 36 + .../099_harvest_reach_segmentation.sql | 554 ++ .../.archived/108_lot_quantity_tracking.sql | 77 + .../.archived/109_enable_rls_critical.sql | 160 + .../.archived/110_rls_zero_policy_tables.sql | 303 + .../111_route_trace_recent_activity.sql | 96 + .../119_regenerate_headgate_token_brand.sql | 30 + .../migrations/.archived/120_water_alerts.sql | 277 + .../.archived/126_founder_pain_log.sql | 47 + .../127_platform_command_center_rpc.sql | 155 + ...get_water_headgates_admin_last_used_at.sql | 11 + .../.archived/129_worker_time_tracking.sql | 569 ++ .../130_time_tracking_pay_period_overtime.sql | 186 + .../133_time_tracking_notifications.sql | 349 + .../.archived/134_abandoned_cart_recovery.sql | 141 + .../.archived/135_email_automation_rpcs.sql | 226 + .../136_email_automation_missing_rpcs.sql | 257 + .../137_time_tracking_workers_and_tasks.sql | 211 + .../.archived/138_time_tracking_logs.sql | 79 + .../139_time_tracking_missing_rpcs.sql | 115 + .../140_time_tracking_field_rpcs.sql | 264 + .../141_update_user_password_rpc.sql | 20 + .../.archived/142_integration_credentials.sql | 85 + .../.archived/143_enable_route_trace.sql | 4 + .../144_time_tracking_worker_number_rls.sql | 116 + .../145_create_product_images_bucket.sql | 17 + .../.archived/146_sitemap_stops_rpc.sql | 32 + .../.archived/147_admin_create_stop_rpcs.sql | 244 + .../.archived/148_public_stops_rpc.sql | 49 + .../149_product_availability_dates.sql | 9 + .../.archived/200_production_features.sql | 258 + .../migrations/.archived/201_seed_data.sql | 23 + .../202_delete_stop_orders_guard_fix.sql | 70 + .../.archived/202_fix_admin_create_stop.sql | 145 + .../.archived/203_locations_table.sql | 470 ++ .../203_plan_usage_active_products.sql | 82 + ...204_admin_users_email_and_auth_subject.sql | 214 + .../.archived/204_authjs_tables.sql | 115 + .../204_locations_public_read_policy.sql | 19 + .../.archived/205_admin_list_locations.sql | 57 + .../206_locations_rededupe_by_city.sql | 142 + .../.archived/207_multi_brand_admin.sql | 185 + .../.archived/208_stops_date_to_date.sql | 73 + .../209_authjs_auto_create_admin.sql | 95 + .../migrations/.archived/BUNDLE_018_042.sql | 4778 ++++++++++++ .../migrations/.archived/XXX_blog_tables.sql | 124 + .../.archived/XXX_launch_checklist.sql | 30 + .../.archived/XXX_roadmap_tables.sql | 69 + .../.archived/XXX_waitlist_table.sql | 45 + .../migrations/.archived/_000_auth_schema.sql | 111 + supabase/push-migrations.js | 155 + tests/unit/auth-actions.test.ts | 9 + tests/unit/create-admin-user.test.ts | 4 +- tests/unit/email-service.test.ts | 8 +- tests/unit/getAdminUser.test.ts | 15 +- tests/unit/sign-in-with-google.test.ts | 4 + vercel.json | 2 +- vitest.config.ts | 2 +- 580 files changed, 52565 insertions(+), 4953 deletions(-) create mode 100644 db/migrations/0000_qa_neon_auth_stub.sql create mode 100644 db/seeds/2026-qa-audit-scale.sql create mode 100644 docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md create mode 100644 docs/qa/audit-2026-06-25/ENV-DIFF.md create mode 100644 docs/qa/audit-2026-06-25/INVENTORY.md create mode 100644 docs/qa/audit-2026-06-25/PLAN.md create mode 100644 docs/qa/eslint-baseline-2026-06-25.log create mode 100644 docs/qa/react-doctor-baseline-2026-06-25.log create mode 100644 docs/qa/tsc-baseline-2026-06-25.log create mode 100644 docs/qa/vitest-baseline-2026-06-25.log create mode 100755 scripts/apply-admin-create-stop.js create mode 100644 scripts/check-stop-fns.js create mode 100644 scripts/check-stop-fns2.js create mode 100644 scripts/check-stop-fns3.js create mode 100644 scripts/cleanup-duplicate-stops.ts create mode 100644 scripts/create-admin-user.ts create mode 100755 scripts/e2e-test.sh create mode 100644 scripts/fix-archived-rls.js create mode 100644 scripts/fix-button-has-type.js create mode 100644 scripts/fix-control-has-associated-label.js create mode 100644 scripts/fix-next-anchor.js create mode 100644 scripts/fix-server-auth-ast.js create mode 100644 scripts/fix-server-auth.js create mode 100644 scripts/fix-zod-format.js create mode 100644 scripts/import-woo-to-route.ts create mode 100644 scripts/seed-admin.ts create mode 100644 scripts/seed-tuxedo.ts create mode 100644 scripts/seed.sh create mode 100644 scripts/seed_tuxedo_tour.py create mode 100644 scripts/upload-tuxedo-video.mjs create mode 100644 scripts/verify-email.ts create mode 100644 scripts/verify-stop-fns.js create mode 100644 src/actions/ai/preferences.ts create mode 100644 src/actions/wholesale.ts create mode 100644 src/app/admin/orders/page.tsx create mode 100644 src/app/admin/products/[id]/page.tsx create mode 100644 src/app/admin/reports/page.tsx create mode 100644 src/app/admin/settings/billing/page.tsx create mode 100644 src/app/admin/settings/integrations/page.tsx create mode 100644 src/app/admin/settings/shipping/page.tsx create mode 100644 src/app/admin/taxes/page.tsx create mode 100644 src/app/indian-river-direct/stops/[slug]/error.tsx create mode 100644 src/app/indian-river-direct/stops/[slug]/loading.tsx create mode 100644 src/app/indian-river-direct/stops/[slug]/page.tsx create mode 100644 src/app/test/page.tsx create mode 100644 src/app/tuxedo/stops/[slug]/error.tsx create mode 100644 src/app/tuxedo/stops/[slug]/loading.tsx create mode 100644 src/app/tuxedo/stops/[slug]/page.tsx create mode 100644 src/app/wholesale/employee/EmployeePortalClient.tsx create mode 100644 src/app/wholesale/portal/WholesalePortalClient.tsx create mode 100644 src/lib/admin-permissions-service.ts create mode 100644 src/lib/oauth/square.ts create mode 100644 src/lib/oauth/stripe.ts create mode 100644 src/lib/safe-html.tsx create mode 100644 src/lib/safe-json.ts create mode 100644 src/lib/safe-window.ts create mode 100644 src/lib/shipping/fedex-token-cache.ts create mode 100644 src/lib/supabase.ts create mode 100644 src/lib/use-prev.ts create mode 100644 src/lib/welcome-email-sender.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/ADMIN_CREATE_STOP_FIX.sql create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/.archived/001_checkout_hardening.sql create mode 100644 supabase/migrations/.archived/002_remove_broad_anon_select.sql create mode 100644 supabase/migrations/.archived/003_audit_logs.sql create mode 100644 supabase/migrations/.archived/004_admin_order_fetch.sql create mode 100644 supabase/migrations/.archived/005_water_log.sql create mode 100644 supabase/migrations/.archived/006_water_log_rpcs_fixed.sql create mode 100644 supabase/migrations/.archived/007_water_log_column_fix.sql create mode 100644 supabase/migrations/.archived/008_water_log_soft_delete.sql create mode 100644 supabase/migrations/.archived/009_water_log_entry_edit_delete.sql create mode 100644 supabase/migrations/.archived/010_water_log_unit_types.sql create mode 100644 supabase/migrations/.archived/011_water_log_consolidated_fix.sql create mode 100644 supabase/migrations/.archived/012_water_log_display_summary.sql create mode 100644 supabase/migrations/.archived/013_water_log_seed_admin.sql create mode 100644 supabase/migrations/.archived/014_water_sessions_rls.sql create mode 100644 supabase/migrations/.archived/015_water_users_security_hardening.sql create mode 100644 supabase/migrations/.archived/016_communication_center.sql create mode 100644 supabase/migrations/.archived/017_communication_contacts.sql create mode 100644 supabase/migrations/.archived/018_contact_import_metadata.sql create mode 100644 supabase/migrations/.archived/019_customers_table.sql create mode 100644 supabase/migrations/.archived/020_order_customer_linking.sql create mode 100644 supabase/migrations/.archived/021_shipping_only_brand.sql create mode 100644 supabase/migrations/.archived/022_operational_events.sql create mode 100644 supabase/migrations/.archived/023_cart_availability_check.sql create mode 100644 supabase/migrations/.archived/024_stop_product_assignment_rpcs.sql create mode 100644 supabase/migrations/.archived/025_fix_assignment_rpc_auth.sql create mode 100644 supabase/migrations/.archived/026_debug_assignment_rpc.sql create mode 100644 supabase/migrations/.archived/027_fix_rpc_signature_conflict.sql create mode 100644 supabase/migrations/.archived/028_fix_caller_uid_type.sql create mode 100644 supabase/migrations/.archived/029_drop_stale_overloads.sql create mode 100644 supabase/migrations/.archived/030_normalize_dev_user_caller_uid.sql create mode 100644 supabase/migrations/.archived/031_reports_v1_rpcs.sql create mode 100644 supabase/migrations/.archived/032_store_employee_role.sql create mode 100644 supabase/migrations/.archived/033_admin_users_crud.sql create mode 100644 supabase/migrations/.archived/034_password_change_flow.sql create mode 100644 supabase/migrations/.archived/035_admin_action_logs.sql create mode 100644 supabase/migrations/.archived/036_user_activity_logs.sql create mode 100644 supabase/migrations/.archived/037_update_admin_user_profile.sql create mode 100644 supabase/migrations/.archived/038_product_images.sql create mode 100644 supabase/migrations/.archived/039_shipping_fulfillment.sql create mode 100644 supabase/migrations/.archived/040_shipping_fulfillment_rpcs.sql create mode 100644 supabase/migrations/.archived/041_payment_settings.sql create mode 100644 supabase/migrations/.archived/042_product_import_rpc.sql create mode 100644 supabase/migrations/.archived/043_square_sync_settings.sql create mode 100644 supabase/migrations/.archived/044_square_sync_log.sql create mode 100644 supabase/migrations/.archived/045_extend_payment_settings_rpcs.sql create mode 100644 supabase/migrations/.archived/046_wholesale_portal.sql create mode 100644 supabase/migrations/.archived/047_wholesale_registration.sql create mode 100644 supabase/migrations/.archived/048_wholesale_enabled.sql create mode 100644 supabase/migrations/.archived/049_wholesale_invoice_settings.sql create mode 100644 supabase/migrations/.archived/050_wholesale_online_payment.sql create mode 100644 supabase/migrations/.archived/051_wholesale_customer_pricing.sql create mode 100644 supabase/migrations/.archived/052_wholesale_credit_limit.sql create mode 100644 supabase/migrations/.archived/053_wholesale_bulk_actions.sql create mode 100644 supabase/migrations/.archived/054_wholesale_email_notifications.sql create mode 100644 supabase/migrations/.archived/055_wholesale_notification_settings.sql create mode 100644 supabase/migrations/.archived/056_wholesale_price_sheet.sql create mode 100644 supabase/migrations/.archived/057_notification_recipients.sql create mode 100644 supabase/migrations/.archived/058_fix_get_wholesale_orders.sql create mode 100644 supabase/migrations/.archived/059_order_status_delete.sql create mode 100644 supabase/migrations/.archived/060_wholesale_pickup_orders.sql create mode 100644 supabase/migrations/.archived/061_soft_delete.sql create mode 100644 supabase/migrations/.archived/062_wholesale_webhooks.sql create mode 100644 supabase/migrations/.archived/063_auto_create_wholesale_settings.sql create mode 100644 supabase/migrations/.archived/064_seed_wholesale_settings.sql create mode 100644 supabase/migrations/.archived/065_fix_get_wholesale_products.sql create mode 100644 supabase/migrations/.archived/066_auto_square_sync.sql create mode 100644 supabase/migrations/.archived/067_checkout_session_id.sql create mode 100644 supabase/migrations/.archived/068_user_carts.sql create mode 100644 supabase/migrations/.archived/069_brand_scoping_phase2.sql create mode 100644 supabase/migrations/.archived/070_rls_policy_audit.sql create mode 100644 supabase/migrations/.archived/071_wholesale_square_sync_toggle.sql create mode 100644 supabase/migrations/.archived/072_orders_brand_scoping.sql create mode 100644 supabase/migrations/.archived/073_pickup_order_items_rpc.sql create mode 100644 supabase/migrations/.archived/074_products_soft_delete.sql create mode 100644 supabase/migrations/.archived/075_stops_soft_delete.sql create mode 100644 supabase/migrations/.archived/076_stops_address_cutoff.sql create mode 100644 supabase/migrations/.archived/077_stops_status.sql create mode 100644 supabase/migrations/.archived/078_wholesale_deposit_guard.sql create mode 100644 supabase/migrations/.archived/079_resend_analytics.sql create mode 100644 supabase/migrations/.archived/080_communication_segments.sql create mode 100644 supabase/migrations/.archived/081_stop_blast_campaign.sql create mode 100644 supabase/migrations/.archived/082_brand_name_in_campaign_email.sql create mode 100644 supabase/migrations/.archived/083_shipping_settings.sql create mode 100644 supabase/migrations/.archived/083_stripe_connect.sql create mode 100644 supabase/migrations/.archived/084_shipments.sql create mode 100644 supabase/migrations/.archived/085_brand_settings.sql create mode 100644 supabase/migrations/.archived/086_brand_settings_email_integration.sql create mode 100644 supabase/migrations/.archived/087_brand_logos_bucket.sql create mode 100644 supabase/migrations/.archived/088_brand_features.sql create mode 100644 supabase/migrations/.archived/089_ai_providers_custom_integrations.sql create mode 100644 supabase/migrations/.archived/090_storefront_settings.sql create mode 100644 supabase/migrations/.archived/091_brand_plan_tier.sql create mode 100644 supabase/migrations/.archived/092_stripe_subscriptions.sql create mode 100644 supabase/migrations/.archived/093_add_hero_image.sql create mode 100644 supabase/migrations/.archived/094_brand_color_customization.sql create mode 100644 supabase/migrations/.archived/095_tax_settings.sql create mode 100644 supabase/migrations/.archived/096_tax_orders_index.sql create mode 100644 supabase/migrations/.archived/097_extend_create_order_with_items.sql create mode 100644 supabase/migrations/.archived/098_products_is_taxable.sql create mode 100644 supabase/migrations/.archived/099_contact_imports_bucket.sql create mode 100644 supabase/migrations/.archived/099_harvest_reach_segmentation.sql create mode 100644 supabase/migrations/.archived/108_lot_quantity_tracking.sql create mode 100644 supabase/migrations/.archived/109_enable_rls_critical.sql create mode 100644 supabase/migrations/.archived/110_rls_zero_policy_tables.sql create mode 100644 supabase/migrations/.archived/111_route_trace_recent_activity.sql create mode 100644 supabase/migrations/.archived/119_regenerate_headgate_token_brand.sql create mode 100644 supabase/migrations/.archived/120_water_alerts.sql create mode 100644 supabase/migrations/.archived/126_founder_pain_log.sql create mode 100644 supabase/migrations/.archived/127_platform_command_center_rpc.sql create mode 100644 supabase/migrations/.archived/128_fix_get_water_headgates_admin_last_used_at.sql create mode 100644 supabase/migrations/.archived/129_worker_time_tracking.sql create mode 100644 supabase/migrations/.archived/130_time_tracking_pay_period_overtime.sql create mode 100644 supabase/migrations/.archived/133_time_tracking_notifications.sql create mode 100644 supabase/migrations/.archived/134_abandoned_cart_recovery.sql create mode 100644 supabase/migrations/.archived/135_email_automation_rpcs.sql create mode 100644 supabase/migrations/.archived/136_email_automation_missing_rpcs.sql create mode 100644 supabase/migrations/.archived/137_time_tracking_workers_and_tasks.sql create mode 100644 supabase/migrations/.archived/138_time_tracking_logs.sql create mode 100644 supabase/migrations/.archived/139_time_tracking_missing_rpcs.sql create mode 100644 supabase/migrations/.archived/140_time_tracking_field_rpcs.sql create mode 100644 supabase/migrations/.archived/141_update_user_password_rpc.sql create mode 100644 supabase/migrations/.archived/142_integration_credentials.sql create mode 100644 supabase/migrations/.archived/143_enable_route_trace.sql create mode 100644 supabase/migrations/.archived/144_time_tracking_worker_number_rls.sql create mode 100644 supabase/migrations/.archived/145_create_product_images_bucket.sql create mode 100644 supabase/migrations/.archived/146_sitemap_stops_rpc.sql create mode 100644 supabase/migrations/.archived/147_admin_create_stop_rpcs.sql create mode 100644 supabase/migrations/.archived/148_public_stops_rpc.sql create mode 100644 supabase/migrations/.archived/149_product_availability_dates.sql create mode 100644 supabase/migrations/.archived/200_production_features.sql create mode 100644 supabase/migrations/.archived/201_seed_data.sql create mode 100644 supabase/migrations/.archived/202_delete_stop_orders_guard_fix.sql create mode 100644 supabase/migrations/.archived/202_fix_admin_create_stop.sql create mode 100644 supabase/migrations/.archived/203_locations_table.sql create mode 100644 supabase/migrations/.archived/203_plan_usage_active_products.sql create mode 100644 supabase/migrations/.archived/204_admin_users_email_and_auth_subject.sql create mode 100644 supabase/migrations/.archived/204_authjs_tables.sql create mode 100644 supabase/migrations/.archived/204_locations_public_read_policy.sql create mode 100644 supabase/migrations/.archived/205_admin_list_locations.sql create mode 100644 supabase/migrations/.archived/206_locations_rededupe_by_city.sql create mode 100644 supabase/migrations/.archived/207_multi_brand_admin.sql create mode 100644 supabase/migrations/.archived/208_stops_date_to_date.sql create mode 100644 supabase/migrations/.archived/209_authjs_auto_create_admin.sql create mode 100644 supabase/migrations/.archived/BUNDLE_018_042.sql create mode 100644 supabase/migrations/.archived/XXX_blog_tables.sql create mode 100644 supabase/migrations/.archived/XXX_launch_checklist.sql create mode 100644 supabase/migrations/.archived/XXX_roadmap_tables.sql create mode 100644 supabase/migrations/.archived/XXX_waitlist_table.sql create mode 100644 supabase/migrations/.archived/_000_auth_schema.sql create mode 100644 supabase/push-migrations.js diff --git a/.gitignore b/.gitignore index e7b0866..575f7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,9 +49,3 @@ playwright-report/ public/videos/tuxedo-hero.mp4 .neon .worktrees/ - -# Local QA audit scratch — never applies to prod -# (Neon Auth stub, scale seed, audit docs created by local audit runs) -db/migrations/0000_qa_*.sql -db/seeds/2026-qa-*.sql -docs/qa/ diff --git a/CLAUDE.md b/CLAUDE.md index 3e10ff1..f59d3ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,45 +12,13 @@ There is exactly one remote — `origin` — pointing to the self-hosted Gitea r Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`). -### Gitea authentication (SSH) - -This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`). - -**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation: - -```bash -eval "$(ssh-agent -s)" -ssh-add ~/.ssh/id_ed25519_crispygoat -``` - -The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding. - -**Verified working commands:** -- `git push origin ` — push (this is the primary workflow) -- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access" - -**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`. - -**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned. - -### Opening pull requests (the workflow that actually works) - -Because no Gitea API token is available in this environment, the PR creation flow is: - -1. Create a feature branch: `git checkout -b docs/-` -2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship) -3. Push: `git push -u origin ` -4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/` — open that URL in a browser to file the PR through the web UI. - -If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token ` on the REST API or `tea logins add -t -u https://git.crispygoat.com` for the CLI. - ## Project Overview Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns. Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 -> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase. +> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. --- @@ -65,12 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit) npx playwright test # Run E2E tests (Playwright) ``` -> The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies. +> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies. > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first. -**Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details. +**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. -E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config. +E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. --- @@ -83,12 +51,12 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp **Auth flow:** 1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set 2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email -3. Middleware (`src/proxy.ts`) guards `/admin/*` routes at the edge level +3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level **Key files:** - `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset) - `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret) -- `src/proxy.ts` — Edge-level route protection +- `src/middleware.ts` — Edge-level route protection - `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API - `src/app/api/auth/forgot-password/route.ts` — Password reset request API - `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API @@ -125,13 +93,13 @@ Server actions are "use server" files that export async functions. Client compon ### Database (Postgres, direct) -The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. +The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. #### Connection - `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var. -- A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool. -- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase. +- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. +- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. #### First production deploy / new prod DB bootstrap (critical for admin access) @@ -287,7 +255,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S ### Communications Module ("Harvest Reach") -The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer. +The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.) `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. @@ -314,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act ## Key Conventions -- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase. +- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`. - `gen_random_uuid()` used in migrations for primary keys - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Status enums stored as TEXT — no PostgreSQL ENUM type @@ -332,7 +300,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act | Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` | | Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` | | Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` | -| Middleware (route protection) | `src/proxy.ts` | +| Middleware (route protection) | `src/middleware.ts` | | Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) | | Admin pages | `src/app/admin/[module]/page.tsx` | | Admin client components | `src/components/admin/*.tsx` | diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 5991cc1..e6cf707 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -10,9 +10,18 @@ Copy `.env.example` → `.env.local` and fill in the values. These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`. +### `NEXT_PUBLIC_SUPABASE_URL` +Your Supabase project URL. +- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL +- **Example:** `https://abc123.supabase.co` + +### `NEXT_PUBLIC_SUPABASE_ANON_KEY` +Supabase anonymous (anon) key — safe for client-side use. +- **Where to get:** Supabase Dashboard → Project Settings → API → anon key + ### `NEXT_PUBLIC_BASE_URL` The base URL of your deployment. Used for OAuth redirects and webhook URLs. -- **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p `) +- **Local:** `http://localhost:3000` - **Production:** `https://yourdomain.com` ### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` @@ -31,16 +40,12 @@ Set to a brand slug to hide other brand routes in single-brand mode. **Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes. -### Database +### Supabase -#### `DATABASE_URL` -Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions. -- **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled) -- **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require` -- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require` - -#### `DATABASE_ADMIN_URL` (optional) -Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset. +#### `SUPABASE_SERVICE_ROLE_KEY` +Full service role key — grants DB admin access bypassing RLS. +- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key +- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. ### Stripe @@ -151,7 +156,7 @@ Controls whether to use Square sandbox or production. | Variable | Local (`.env.local`) | Production (hosting dashboard) | |---|---|---| -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` | | `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` | | `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` | | `SQUARE_ENVIRONMENT` | `sandbox` | `production` | @@ -163,6 +168,7 @@ Controls whether to use Square sandbox or production. - **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. +- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard. - **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. --- diff --git a/LAUNCH_CHECKLIST.md b/LAUNCH_CHECKLIST.md index 7c9bcc7..cb96c50 100644 --- a/LAUNCH_CHECKLIST.md +++ b/LAUNCH_CHECKLIST.md @@ -1,7 +1,5 @@ # Route Commerce Launch Checklist Summary -**Last updated:** 2026-06-25 - ## Overview This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. @@ -10,15 +8,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo ## ✅ Implementation Status -### 1. Database & Auth Foundation ✓ +### 1. Supabase Foundation ✓ | Component | Status | Location | |-----------|--------|----------| -| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local | -| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` | -| Role-Based Access | ✓ Complete | `admin-permissions.ts` system | -| Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) | -| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` | +| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth | +| Database Structure | ✓ Complete | Supabase migrations in place | +| Role-Based Access | ✓ Complete | admin-permissions.ts system | +| Protected Routes | ✓ Complete | middleware.ts | +| Server Actions Pattern | ✓ Complete | src/actions/*.ts | ### 2. Launch-Ready Features ✓ @@ -239,9 +237,9 @@ Track these metrics post-launch: - **Documentation**: See CLAUDE.md for technical details - **Stripe Dashboard**: https://dashboard.stripe.com -- **Neon Console**: https://console.neon.tech +- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp - **Vercel Deployments**: https://vercel.com/dashboard --- -*Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)* \ No newline at end of file +*Generated: January 2025* \ No newline at end of file diff --git a/MEMORY.md b/MEMORY.md index 630965f..71eee96 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,9 +2,7 @@ This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. -**Last updated:** 2026-06-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers; documented the SSH-based Gitea workflow) - -> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints. +**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work) ## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043) @@ -50,79 +48,80 @@ See also the plan doc referenced in deploy.yml for the broader reliability work. ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres -**Status: COMPLETE (2026-06).** All Supabase references removed from the codebase: -- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone). -- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`. -- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`. -- `eslint.config.mjs` no longer ignores `supabase/**`. -- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references. -- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script. +The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture. -This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical. +### What changes immediately +- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`. +- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`. +- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**). +- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth. +- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**. -### Original pivot summary (kept for context) -The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture. - -### What changed at the time -- **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`. -- **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction. -- **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping. -- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass. -- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately. +### What's TBD / needs follow-up +- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet) +- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided** +- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md) +- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings? +- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition +- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally? ### Migration content that's now obsolete - **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice. -- **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world. +- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world. +- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work. ### Historical sections below -The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. +The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. --- -## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)* +## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)* ### Login + Link (done in this session) - User ran `supabase login` - We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp` - Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`. -- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. -- This enabled `supabase db query --linked`, `supabase migration list`, etc. +- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. +- This enables `supabase db query --linked`, `supabase migration list`, etc. ### Important Environment Note - Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace: - `getaddrinfo ENOTFOUND` - IPv6 "network is unreachable" in some cases - HTTPS / Supabase REST / Management API work fine. -- Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here. +- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here. -### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead) -File: `supabase/push-migrations.js` *(deleted)* +### Updated Migration Script +File: `supabase/push-migrations.js` -Key behavior (historical): -- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). -- `pushWithCli()` applied files one-by-one using: +Key changes: +- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). +- `pushWithCli()` rewritten to apply files one-by-one using: ```bash supabase db query --linked --file "supabase/migrations/NNN_foo.sql" ``` (Previously tried `db push --db-url ...` which used direct connection and failed here.) -- Fell back to direct `pg` only if CLI path failed. +- Falls back to direct `pg` only if CLI path fails. +- Header comments updated with current recommended workflow. -**Historical commands (Supabase CLI path — both scripts are gone):** +**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):** ```bash -# Supabase CLI path (legacy — script deleted, do not use) +# Supabase CLI path (legacy — do not use going forward) supabase login supabase link --project-ref wnzkhezyhnfzhkhiflrp -node supabase/push-migrations.js 148 # CLI path (script deleted) +node supabase/push-migrations.js 148 # CLI path # or npm run migrate:one 148 ``` ```bash -# Direct pg path (this was the future — now the only path, via scripts/migrate.js) +# Direct pg path (this is the future — only the pg branch is kept alive in the script) +DATABASE_URL=postgres://... node supabase/push-migrations.js 148 +# or DATABASE_URL=postgres://... npm run migrate:one 148 ``` -`npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution). +`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). --- @@ -211,14 +210,16 @@ Verification queries (post-apply) confirmed: --- -## Current State / Gotchas (2026-06) +## Current State / Gotchas (2026-06-06) -- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo. -- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied. -- Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe. -- When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved. -- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle). +- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only. +- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.) +- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase. +- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`. +- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch. +- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. +- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js). --- @@ -233,9 +234,9 @@ Verification queries (post-apply) confirmed: ## Unrelated / Other Changes in Tree (as of last git status) -(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.) +(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.) -This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration. +This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request. --- diff --git a/PRODUCTION_DEPLOYMENT_CHECKLIST.md b/PRODUCTION_DEPLOYMENT_CHECKLIST.md index 8bb51a7..10b5388 100644 --- a/PRODUCTION_DEPLOYMENT_CHECKLIST.md +++ b/PRODUCTION_DEPLOYMENT_CHECKLIST.md @@ -1,38 +1,47 @@ # Route Commerce — Production Deployment Checklist -**Platform Version:** 2.0 -**Last Updated:** 2026-06-25 -**Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx +**Platform Version:** 1.6 +**Last Updated:** 2026-05-13 +**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx --- ## 1. Migration Order -Apply migrations in number order. The full set lives in `db/migrations/`: +Apply migrations in number order. All numbered `001`–`092` are required. ```bash -# Push all migrations (idempotent — `_migrations` table tracks what's applied) -npm run migrate +# Push all migrations (sequential) +npm run migrate:one 001 +npm run migrate:one 002 +# ... through ... +npm run migrate:one 092 + +# Or push all at once via Supabase CLI +supabase db push ``` -The current migration set is consolidated (the Supabase-era 001–092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes. - -**Current migrations in `db/migrations/`:** +**Key migrations (last 15):** | # | File | Purpose | |---|------|---------| -| 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) | -| 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). | -| 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` | -| 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning | -| 0040 | `0040_command_center.sql` | Adds command-center dashboard tables | -| 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center | -| 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) | -| 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` | -| 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs | -| 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard | +| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` | +| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables | +| 080 | `communication_segments.sql` | Adds `communication_segments` table | +| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC | +| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` | +| 083 | `shipping_settings.sql` | Adds `shipping_settings` table | +| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking | +| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) | +| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` | +| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket | +| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system | +| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage | +| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC | +| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC | +| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs | -> **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated. +> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes. --- @@ -44,11 +53,10 @@ Copy `.env.local.example` to `.env.local` and fill in all values. | Variable | Required | Description | |---|---|---| -| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) | -| `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. | -| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL | -| `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) | -| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header | +| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | +| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | +| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | ### Payments @@ -190,13 +198,17 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co } ``` -**Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs). +**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active: +```sql +SELECT * FROM pg_publication_tables WHERE tablename = 'stops'; +``` +If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;` --- ## 5. Feature Flag Enablement (Post-Deploy) -After deployment, enable add-ons per brand via the admin UI or direct Postgres: +After deployment, enable add-ons per brand via the admin UI or Supabase SQL: ```sql -- Enable Harvest Reach for a brand @@ -290,13 +302,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo ### Database Rollback ```bash -# Revert last migration via Postgres -# (Restore from a `pg_dump` snapshot taken before the migration was applied) +# Revert last migration via Supabase +supabase db reset --backup-id # Or manually revert (example for migration 090) psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" ``` -> **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss. +> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. ### Environment Variable Rollback Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. @@ -306,16 +318,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi ## 8. Known Limitations & Future Work ### Known Issues -- **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. +- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. - **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. -- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code. +- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. - **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. - **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. - **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. - **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. - **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. - **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. -- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code. +- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. ### Future Work (Non-Blocking) - **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) @@ -332,9 +344,11 @@ Environment variables are version-controlled in Vercel. Restore a previous versi | Role | Contact | |------|---------| -| Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` | +| Platform Admin | Set via Supabase Auth dashboard | | Kyle Martinez | Primary contact — Route Commerce owner | -| Neon Support | neon.tech/support | +| Supabase Support | supabase.com/support | | Vercel Support | vercel.com/support | -For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. \ No newline at end of file +For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. + +For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab. \ No newline at end of file diff --git a/PRODUCTION_SETUP.md b/PRODUCTION_SETUP.md index c7effcc..2994255 100644 --- a/PRODUCTION_SETUP.md +++ b/PRODUCTION_SETUP.md @@ -15,16 +15,16 @@ npm run dev ## Configuration -### 1. Neon Auth (Better Auth) +### 1. Clerk Authentication -1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`) -2. Copy keys to `.env.local`: +1. Sign up at [Clerk](https://clerk.com) +2. Create a new application +3. Copy keys to `.env.local`: ``` - NEON_AUTH_BASE_URL=https://xxx.neonauth.io - NEON_AUTH_COOKIE_SECRET= - NEXT_PUBLIC_SITE_URL=https://yourdomain.com + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx + CLERK_SECRET_KEY=sk_test_xxx ``` -3. Configure middleware in `src/proxy.ts` +4. Configure middleware in `src/proxy.ts` ### 2. Stripe Payments @@ -37,15 +37,15 @@ npm run dev STRIPE_WEBHOOK_SECRET=whsec_xxx ``` -### 3. Database (Postgres / Neon) +### 3. Supabase Database -The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used. - -1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth) +1. Create project at [Supabase](https://supabase.com) 2. Run migrations: `npm run migrate` 3. Update `.env.local` with: ``` - DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require + NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx + SUPABASE_SERVICE_ROLE_KEY=xxx ``` ### 4. Optional Services @@ -74,7 +74,7 @@ Ensure environment variables are set before deployment. ## Key Files -- `src/proxy.ts` - Neon Auth (Better Auth) middleware +- `src/proxy.ts` - Clerk middleware - `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/analytics.ts` - PostHog analytics - `src/lib/sentry.ts` - Sentry error tracking @@ -82,7 +82,7 @@ Ensure environment variables are set before deployment. - `src/components/onboarding/OnboardingFlow.tsx` - User onboarding - `src/components/referral/ReferralSystem.tsx` - Referral tracking - `src/components/changelog/ChangelogFeed.tsx` - Product updates -- `db/migrations/` - Database schema (applied via `scripts/migrate.js`) +- `supabase/migrations/` - Database schema ## Scripts diff --git a/README.md b/README.md index f415837..c623929 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations: ## Tech Stack - **Framework:** Next.js 16 (App Router) -- **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth) +- **Database:** Supabase (Postgres + Auth + RLS) - **Payments:** Stripe + Square - **Email/SMS:** Resend - **Styling:** Tailwind CSS v4 @@ -37,8 +37,10 @@ npm install Create a `.env.local` file with: ```bash -# Database (Postgres) -DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Stripe STRIPE_SECRET_KEY=sk_test_... @@ -54,9 +56,10 @@ RESEND_API_KEY=re_... ### 3. Set up the database -The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI): +Link your Supabase project, then push migrations: ```bash +supabase link --project-ref npm run migrate ``` @@ -72,7 +75,7 @@ npm run migrate:one 83 npm run dev ``` -Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p ` if you need a different port.) +Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. ### 5. Dev auth bypass @@ -108,7 +111,7 @@ src/ │ ├── admin/ # Admin-specific components │ └── storefront/ # Storefront components └── lib/ # Utilities, auth, feature flags, formatting -db/ +supabase/ └── migrations/ # SQL migrations (numbered sequentially) ``` @@ -250,7 +253,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate ```bash # Run abandoned cart with verbose output -curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \ +curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \ -H "Authorization: Bearer local-dev-secret" \ -H "Content-Type: application/json" @@ -312,8 +315,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel ## Notes -- All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway -- Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production +- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly +- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production - Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs - Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` - Migration files are numbered sequentially — never reuse numbers \ No newline at end of file diff --git a/REPORT.md b/REPORT.md index 8897fa1..d861882 100644 --- a/REPORT.md +++ b/REPORT.md @@ -4,8 +4,6 @@ **Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out all Supabase references from the auth/admin path. -> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`. - --- ## What was built @@ -173,7 +171,7 @@ src/actions/ai/preferences.ts 1. **Provision a `pg` pool in production.** `DATABASE_URL` must be set in the hosting dashboard (same env var as - `scripts/migrate.js` uses). + `supabase/push-migrations.js` uses). 2. **Apply migration 204** if not already applied — adds `email`, `auth_provider`, `auth_subject` columns to `admin_users` and the `get_admin_user_for_session` RPC. @@ -194,15 +192,6 @@ src/actions/ai/preferences.ts 7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from `package.json`** (last step once no file imports them). -### Resolution (2026-06-25) - -Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are -complete. The codebase is now Supabase-free at the source-code level — -`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only -remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a -handful of historical `docs/superpowers/{plans,specs}/` files, all kept -as historical record of the migration. - --- ## Commands diff --git a/db/client.ts b/db/client.ts index f82f980..950d9af 100644 --- a/db/client.ts +++ b/db/client.ts @@ -23,22 +23,38 @@ */ import "server-only"; +import { Pool, type PoolClient } from "pg"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import * as schema from "./schema"; -import { getPool, withTx } from "@/lib/db"; type Schema = typeof schema; export type Db = NodePgDatabase; -/** - * The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the - * app connects to Postgres through a single pool. Connection settings - * (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`, - * `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config. - * - * The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from - * `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home. - */ +let _pool: Pool | null = null; + +function getPool(): Pool { + if (_pool) return _pool; + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error( + "DATABASE_URL is not set. Add it to .env.local (see .env.example).", + ); + } + _pool = new Pool({ + connectionString, + max: parseInt(process.env.PG_POOL_MAX ?? "10", 10), + idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10), + connectionTimeoutMillis: parseInt( + process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000", + 10, + ), + allowExitOnIdle: false, + }); + _pool.on("error", (err) => { + console.error("[db] idle client error", err); + }); + return _pool; +} /** * Run `fn` with a Drizzle client. No brand context is set — the caller @@ -67,7 +83,7 @@ export async function withBrand( brandId: string, fn: (db: Db) => Promise, ): Promise { - return withTx(async (client) => { + return runInTransaction(async (client) => { // set_config(setting, value, is_local) — is_local=true makes it // transaction-local so it auto-resets at COMMIT/ROLLBACK and never // leaks across pooled connections. @@ -87,7 +103,7 @@ export async function withBrand( export async function withPlatformAdmin( fn: (db: Db) => Promise, ): Promise { - return withTx(async (client) => { + return runInTransaction(async (client) => { await client.query("SELECT set_config('app.current_brand_id', '', true)"); await client.query("SELECT set_config('app.platform_admin', 'true', true)"); const db = drizzle(client, { schema }); @@ -95,4 +111,25 @@ export async function withPlatformAdmin( }); } +async function runInTransaction( + fn: (client: PoolClient) => Promise, +): Promise { + const client = await getPool().connect(); + try { + await client.query("BEGIN"); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (err) { + try { + await client.query("ROLLBACK"); + } catch { + // ignore secondary rollback failure + } + throw err; + } finally { + client.release(); + } +} + export { schema }; diff --git a/db/migrations/0000_qa_neon_auth_stub.sql b/db/migrations/0000_qa_neon_auth_stub.sql new file mode 100644 index 0000000..d74d5a0 --- /dev/null +++ b/db/migrations/0000_qa_neon_auth_stub.sql @@ -0,0 +1,12 @@ +-- QA audit stub for Neon Auth (not needed in production). +-- Creates the minimum neon_auth schema required by FK constraints in +-- subsequent migrations. Real provisioning happens via Neon Auth in prod. +-- This file is namespaced with 0000_ so it sorts/applies before 0001_init.sql. + +CREATE SCHEMA IF NOT EXISTS neon_auth; +CREATE TABLE IF NOT EXISTS neon_auth.user ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); \ No newline at end of file diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql index f64d4c0..9338bee 100644 --- a/db/migrations/0001_init.sql +++ b/db/migrations/0001_init.sql @@ -1764,7 +1764,7 @@ DROP POLICY IF EXISTS tenant_isolation ON changelogs; CREATE POLICY tenant_isolation ON changelogs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); DROP POLICY IF EXISTS tenant_isolation ON changelog_reads; -CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (true) WITH CHECK (true); +CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (user_id = auth.uid() OR is_platform_admin()) WITH CHECK (user_id = auth.uid() OR is_platform_admin()); DROP POLICY IF EXISTS tenant_isolation ON onboarding_progress; CREATE POLICY tenant_isolation ON onboarding_progress FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL); diff --git a/db/migrations/0002_admin_password.sql b/db/migrations/0002_admin_password.sql index 588111a..35b89ed 100644 --- a/db/migrations/0002_admin_password.sql +++ b/db/migrations/0002_admin_password.sql @@ -1,17 +1,20 @@ --- 0002_admin_password.sql (no-op) +-- 0002_admin_password.sql -- --- This migration slot is preserved for historical DB compatibility. --- The original content added a `password_hash` column to a legacy `users` --- table used by the old Auth.js Credentials provider. That table no longer --- exists — auth moved to Neon Auth (Better Auth), which manages its own --- `neon_auth.user` schema. See CLAUDE.md "Authentication & Authorization". +-- Adds a `password_hash` column to `users` so the Auth.js Credentials +-- provider can verify email + password against the database. -- --- Production DBs that ran the original migration already have this filename --- recorded in `_migrations` and will skip it. Fresh DBs would otherwise --- fail on `ALTER TABLE users`; this no-op keeps the apply path clean while --- preserving the migration numbering (0003_*.sql onward is unaffected). +-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable +-- because OAuth-only users (Google) never set a password. -- --- Do not reintroduce an `ALTER TABLE users` here — the table does not exist --- and Neon Auth owns user identity. +-- The Credentials provider's `authorize` function is documented in +-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword` +-- (see `src/lib/passwords.ts`) before returning the user. -SELECT 1; \ No newline at end of file +-- Transaction is managed by the migrate runner (scripts/migrate.js). +-- File kept small and re-runnable via IF NOT EXISTS on the ALTER. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS password_hash TEXT; + +-- Update the updated_at trigger tracking — no new triggers needed since +-- `users` already has `set_updated_at` from migration 0001. diff --git a/db/seeds/2026-qa-audit-scale.sql b/db/seeds/2026-qa-audit-scale.sql new file mode 100644 index 0000000..0e5b737 --- /dev/null +++ b/db/seeds/2026-qa-audit-scale.sql @@ -0,0 +1,412 @@ +-- QA audit production-scale seed. +-- Idempotent (safe to re-run). Run via: docker exec -i routeqa-pg psql -U routeqa -d route_comm < db/seeds/2026-qa-audit-scale.sql +-- +-- Per brand (tuxedo, indian-river-direct): +-- 500 products, 50 stops, 500 customers, 1000 orders, ~2500 order_items +-- 5 communication templates, 25 contacts, 8 campaigns, 200 message_logs +-- 50 wholesale_customers, 200 wholesale_orders +-- 200 time_tracking_logs, 100 water_log_entries, 50 audit_logs + +BEGIN; + +-- ============================================================================ +-- 1. Brands +-- ============================================================================ +INSERT INTO brands (id, name, slug, plan_tier, max_users, max_stops_monthly, max_products) +VALUES + ('11111111-1111-1111-1111-111111111111', 'Tuxedo Citrus', 'tuxedo', 'farm', 5, 999, 999), + ('22222222-2222-2222-2222-222222222222', 'Indian River Direct', 'indian-river-direct', 'enterprise', 50, 9999, 9999) +ON CONFLICT (id) DO NOTHING; + +-- ============================================================================ +-- 2. Brand settings +-- ============================================================================ +INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, website_url, + street_address, city, state, postal_code, country, + tagline, about_html, primary_color, from_email, from_name, custom_footer_text) +VALUES + ('11111111-1111-1111-1111-111111111111', + 'Tuxedo Citrus Co.', '(555) 010-2200', 'hello@tuxedocitrus.example', + 'https://tuxedocitrus.example', '123 Grove Lane', 'Vero Beach', 'FL', '32960', 'US', + 'Sun-ripened citrus, delivered.', + '

Family-run citrus grove in the Indian River region.

', + '#F59E0B', 'orders@tuxedocitrus.example', 'Tuxedo Citrus', '© Tuxedo Citrus Co.'), + ('22222222-2222-2222-2222-222222222222', + 'Indian River Direct LLC', '(555) 010-3300', 'orders@indianriverdirect.example', + 'https://indianriverdirect.example', '456 Citrus Way', 'Fort Pierce', 'FL', '34950', 'US', + 'From our groves to your store.', + '

Direct-from-grove wholesale produce.

', + '#0F766E', 'orders@indianriverdirect.example', 'Indian River Direct', '© Indian River Direct') +ON CONFLICT (brand_id) DO NOTHING; + +-- ============================================================================ +-- 3. Wholesale settings (per actual schema) +-- ============================================================================ +INSERT INTO wholesale_settings (brand_id, require_approval, min_order_amount, online_payment_enabled, pickup_location, fob_location, from_email, invoice_business_name) +VALUES + ('11111111-1111-1111-1111-111111111111', true, 500.00, true, '123 Grove Lane, Vero Beach FL', 'Origin Vero Beach FL', 'orders@tuxedocitrus.example', 'Tuxedo Citrus Co.'), + ('22222222-2222-2222-2222-222222222222', false, 250.00, false, '456 Citrus Way, Fort Pierce FL', 'Origin Fort Pierce FL', 'orders@indianriverdirect.example','Indian River Direct LLC') +ON CONFLICT (brand_id) DO NOTHING; + +-- ============================================================================ +-- 4. Admin users + Neon Auth users +-- ============================================================================ +INSERT INTO neon_auth.user (id, email, name) VALUES + ('aaaa1111-1111-1111-1111-111111111111', 'qa-platform@routecomm.example', 'QA Platform Admin'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'qa-tuxedo@routecomm.example', 'QA Tuxedo Admin'), + ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'qa-ird@routecomm.example', 'QA IRD Admin') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO admin_users (id, user_id, email, name, role, brand_id, + can_manage_orders, can_manage_products, can_manage_stops, + can_manage_customers, can_manage_wholesale, can_manage_billing, + can_manage_settings, can_manage_water_log, can_manage_time_tracking, + can_manage_route_trace, can_manage_reports, can_manage_communications, + can_manage_pickup, can_manage_messages, can_manage_refunds, + can_manage_users, active, display_name) +VALUES + ('aaaa1111-1111-1111-1111-111111111111', + 'aaaa1111-1111-1111-1111-111111111111', + 'qa-platform@routecomm.example', 'QA Platform Admin', 'platform_admin', NULL, + true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, + 'QA Platform Admin'), + ('bbbb1111-1111-1111-1111-111111111111', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'qa-tuxedo@routecomm.example', 'QA Tuxedo Admin', 'brand_admin', + '11111111-1111-1111-1111-111111111111', + true, true, true, true, false, false, false, false, false, false, true, false, true, true, false, false, true, + 'QA Tuxedo Admin'), + ('cccc1111-1111-1111-1111-111111111111', + 'cccccccc-cccc-cccc-cccc-cccccccccccc', + 'qa-ird@routecomm.example', 'QA IRD Admin', 'brand_admin', + '22222222-2222-2222-2222-222222222222', + true, true, true, true, true, false, true, false, false, false, true, true, true, true, false, false, true, + 'QA IRD Admin') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO admin_user_brands (admin_user_id, brand_id) VALUES + ('bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111'), + ('cccc1111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222') +ON CONFLICT (admin_user_id, brand_id) DO NOTHING; + +-- ============================================================================ +-- 5. Products: 500 per brand +-- ============================================================================ +INSERT INTO products (brand_id, name, description, sku, type, price_cents, inventory, unit, active, is_taxable, pickup_type, image_url) +SELECT + b.id, + CASE (i % 50) + WHEN 0 THEN 'Grapefruit — Ruby Red #' || i + WHEN 1 THEN 'Orange — Valencia #' || i + WHEN 2 THEN 'Orange — Navel #' || i + WHEN 3 THEN 'Tangerine — Honeybell #' || i + WHEN 4 THEN 'Lime — Persian #' || i + WHEN 5 THEN 'Lemon — Meyer #' || i + WHEN 6 THEN 'Avocado — Hass #' || i + WHEN 7 THEN 'Mango — Kent #' || i + WHEN 8 THEN 'Strawberry — Albion #' || i + WHEN 9 THEN 'Blueberry — Duke #' || i + ELSE 'Citrus Mix #' || i + END AS name, + 'Sample description for product #' || i || ' — long enough to wrap on small screens. Unicode: αβγ δεζ 🌿🍊' AS description, + 'SKU-' || substring(md5(b.id::text || i::text), 1, 8) AS sku, + CASE (i % 10) WHEN 0 THEN 'wholesale' WHEN 1 THEN 'both' ELSE 'standard' END, + ((100 + (i * 7) % 5000))::int, + CASE WHEN i % 47 = 0 THEN 0 ELSE (10 + (i * 3) % 200) END, + CASE (i % 4) WHEN 0 THEN 'lb' WHEN 1 THEN 'each' WHEN 2 THEN 'box' ELSE 'bushel' END, + (i % 23 != 0), + (i % 4 = 0), + CASE (i % 3) WHEN 0 THEN 'pickup' WHEN 1 THEN 'ship' ELSE 'all' END, + 'https://placehold.co/600x400?text=P' || i +FROM brands b +CROSS JOIN generate_series(1, 500) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 6. Stops: 50 per brand +-- ============================================================================ +INSERT INTO stops (brand_id, name, location, address, city, state, zip, date, time, cutoff_date, status, is_public, notes) +SELECT + b.id, + 'Stop ' || i || ' — ' || (ARRAY['Downtown','North Park','Westside','East Village','South Bay','Uptown','Riverside','Lakeside'])[1 + (i % 8)], + (ARRAY['City Hall Lot','Park & Ride','Community Center','Church Lot','School Lot','Shopping Plaza'])[1 + (i % 6)], + (100 + (i * 13) % 9000)::text || ' Main St', + (ARRAY['Vero Beach','Fort Pierce','Port St. Lucie','Sebastian','Stuart','Palm Bay'])[1 + (i % 6)], + 'FL', + lpad(((32950 + (i * 7) % 50))::text, 5, '0'), + (CURRENT_DATE + ((i - 25) * 7))::date, + CASE (i % 4) WHEN 0 THEN '09:00-11:00' WHEN 1 THEN '12:00-14:00' WHEN 2 THEN '15:00-17:00' ELSE '18:00-20:00' END, + (CURRENT_DATE + ((i - 25) * 7) - 2)::date, + CASE (i % 13) WHEN 0 THEN 'paused' WHEN 1 THEN 'closed' ELSE 'active' END, + (i % 7 != 0), + CASE WHEN i % 19 = 0 THEN 'Special instructions: bring cash only — unicode ✓' ELSE NULL END +FROM brands b +CROSS JOIN generate_series(1, 50) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 7. Customers: 500 per brand +-- ============================================================================ +INSERT INTO customers (brand_id, primary_email, primary_phone, first_name, last_name, source, metadata) +SELECT + b.id, + 'cust' || i || '-' || substring(b.id::text, 1, 4) || '@routecomm.example', + CASE WHEN i % 5 = 0 THEN NULL ELSE '+1555' || lpad(((1000000 + i * 37) % 9999999)::text, 7, '0') END, + (ARRAY['Alex','Sam','Jordan','Casey','Riley','Morgan','Taylor','Jamie','Avery','Quinn'])[1 + (i % 10)], + (ARRAY['Smith','Johnson','Williams','Brown','Jones','Garcia','Miller','Davis','Rodriguez','Martinez'])[1 + ((i/10) % 10)], + CASE (i % 6) WHEN 0 THEN 'system' WHEN 1 THEN 'wholesale_application' WHEN 2 THEN 'manual' WHEN 3 THEN 'import' WHEN 4 THEN 'referral' ELSE 'walk_in' END, + jsonb_build_object('sms_opt_in', (i % 3 != 0), 'email_opt_in', (i % 9 != 0)) +FROM brands b +CROSS JOIN generate_series(1, 500) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 8. Orders: 1000 per brand +-- ============================================================================ +INSERT INTO orders (brand_id, customer_id, stop_id, total_cents, status, fulfillment, + customer_address, customer_city, customer_state, customer_zip, + idempotency_key, notes, placed_at) +SELECT + b.id, + (SELECT id FROM customers WHERE brand_id = b.id ORDER BY random() LIMIT 1), + CASE WHEN i % 3 = 0 THEN NULL + ELSE (SELECT id FROM stops WHERE brand_id = b.id AND status='active' ORDER BY random() LIMIT 1) END, + CASE WHEN i % 73 = 0 THEN 0 ELSE (500 + (i * 11) % 30000) END, + (ARRAY['pending','confirmed','fulfilled','canceled'])[1 + (i % 4)], + (ARRAY['pickup','ship','mixed'])[1 + (i % 3)], + (100 + (i * 7) % 9000)::text || ' ' || + (ARRAY['Oak','Pine','Maple','Elm','Birch','Cedar'])[1 + (i % 6)] || ' St', + (ARRAY['Vero Beach','Fort Pierce','Stuart','Sebastian'])[1 + (i % 4)], + 'FL', + lpad(((32950 + (i * 11) % 50))::text, 5, '0'), + 'qa-order-' || b.id || '-' || i, + CASE WHEN i % 41 = 0 THEN 'Customer note with unicode: gracias 谢谢 🎉' ELSE NULL END, + NOW() - ((i % 365) || ' days')::interval +FROM brands b +CROSS JOIN generate_series(1, 1000) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222'); +-- Note: no ON CONFLICT for orders — idempotency_key has no unique index. +-- Re-running this seed after a full db reset is safe; re-running without +-- reset will duplicate orders (acceptable for QA-only environment). + +INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment) +SELECT + o.id, + (SELECT id FROM products WHERE brand_id = o.brand_id ORDER BY random() LIMIT 1), + (1 + (row_number() OVER (PARTITION BY o.id) % 3)), + (500 + (row_number() OVER (PARTITION BY o.id) * 137) % 5000), + CASE WHEN o.fulfillment = 'mixed' + THEN (ARRAY['pickup','ship'])[1 + (row_number() OVER (PARTITION BY o.id) % 2)] + ELSE o.fulfillment END +FROM orders o +WHERE o.idempotency_key LIKE 'qa-order-%' + AND NOT EXISTS (SELECT 1 FROM order_items WHERE order_id = o.id); + +UPDATE orders o +SET total_cents = COALESCE((SELECT SUM(quantity * price_cents) FROM order_items WHERE order_id = o.id), 0) +WHERE o.idempotency_key LIKE 'qa-order-%'; + +-- ============================================================================ +-- 9. Communication templates + campaigns + contacts + message logs +-- ============================================================================ +INSERT INTO communication_templates (brand_id, name, subject, body_text, body_html, template_type, campaign_type) +SELECT b.id, t.name, t.subject, t.body_text, t.body_html, t.template_type, t.campaign_type +FROM brands b +CROSS JOIN (VALUES + ('Welcome', 'Welcome to our store!', 'Hello and welcome aboard.', '

Hello — welcome aboard.

', 'transactional', 'welcome'), + ('Order Confirmation', 'Your order is confirmed', 'Order #{{order_id}} confirmed.', '

Order #{{order_id}} confirmed.

', 'transactional', 'order'), + ('Stop Reminder', 'Pickup tomorrow at {{stop_name}}', 'Don''t forget your pickup tomorrow.', '

Don''t forget your pickup tomorrow.

', 'transactional', 'reminder'), + ('Sale Announcement', '🌟 20% off this week', 'Sale ends Friday — unicode ✓.', '

Sale ends Friday — unicode ✓.

', 'marketing', 'promo'), + ('Abandoned Cart', 'You left items in your cart', 'Come back and finish your order.', '

Come back and finish your order.

', 'marketing', 'abandoned_cart') +) AS t(name, subject, body_text, body_html, template_type, campaign_type) +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO communication_contacts (brand_id, email, phone, first_name, last_name, sms_opt_in, email_opt_in, source) +SELECT + b.id, + 'comm' || i || '-' || substring(b.id::text,1,4) || '@routecomm.example', + '+1555' || lpad((1000000 + i * 13)::text, 7, '0'), + 'Contact' || i, + 'Last' || i, + (i % 3 != 0), + (i % 9 != 0), + 'manual' +FROM brands b +CROSS JOIN generate_series(1, 25) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO communication_campaigns (brand_id, template_id, name, subject, body_text, status, campaign_type) +SELECT + b.id, + (SELECT id FROM communication_templates WHERE brand_id = b.id ORDER BY random() LIMIT 1), + 'Campaign ' || i || ' — ' || (ARRAY['Welcome push','Holiday sale','Re-engagement','Newsletter','Flash sale','Lapsed buyers','New arrivals','Survey'])[1 + (i % 8)], + 'Campaign Subject ' || i, + 'Campaign body ' || i, + (ARRAY['draft','scheduled','sending','sent','sent','sent','sent','sent'])[1 + (i % 8)], + CASE (i % 4) WHEN 0 THEN 'promo' WHEN 1 THEN 'newsletter' WHEN 2 THEN 'reminder' ELSE 'welcome' END +FROM brands b +CROSS JOIN generate_series(1, 8) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO communication_message_logs (brand_id, campaign_id, contact_id, customer_email, delivery_method, status, subject, body_preview, sent_at) +SELECT + b.id, + (SELECT id FROM communication_campaigns WHERE brand_id = b.id AND status='sent' ORDER BY random() LIMIT 1), + (SELECT id FROM communication_contacts WHERE brand_id = b.id ORDER BY random() LIMIT 1), + 'comm' || (i % 25 + 1) || '-' || substring(b.id::text,1,4) || '@routecomm.example', + CASE WHEN i % 5 = 0 THEN 'sms' ELSE 'email' END, + (ARRAY['sent','delivered','opened','clicked','bounced','failed'])[1 + (i % 6)], + 'Subject ' || i, + 'Body preview ' || i || ' — unicode: 谢谢 ✓', + NOW() - ((i % 90) || ' days')::interval +FROM brands b +CROSS JOIN generate_series(1, 200) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 10. Wholesale customers + orders +-- ============================================================================ +INSERT INTO wholesale_customers (brand_id, company_name, contact_name, email, phone, account_status, credit_limit, deposits_enabled, role) +SELECT + b.id, + 'Wholesale ' || (ARRAY['Market','Grocers','Foods','Distribs','Co-op','Trading','Imports','Group'])[1 + (i % 8)] || ' #' || i, + 'Buyer ' || i, + 'ws' || i || '-' || substring(b.id::text,1,4) || '@routecomm.example', + '+1555' || lpad((2000000 + i * 17)::text, 7, '0'), + CASE (i % 5) WHEN 0 THEN 'inactive' WHEN 1 THEN 'suspended' ELSE 'active' END, + (50000 + (i * 1000) % 500000)::numeric, + (i % 3 = 0), + 'buyer' +FROM brands b +CROSS JOIN generate_series(1, 50) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO wholesale_orders (brand_id, customer_id, status, fulfillment_status, payment_status, subtotal, balance_due, anticipated_pickup_date) +SELECT + b.id, + (SELECT id FROM wholesale_customers WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (ARRAY['pending','confirmed','in_production','ready','fulfilled','canceled'])[1 + (i % 6)], + (ARRAY['unfulfilled','partial','fulfilled'])[1 + (i % 3)], + (ARRAY['unpaid','deposit_paid','paid','refunded'])[1 + (i % 4)], + (10.00 + (i * 41) % 500.00)::numeric, + (0)::numeric, + CURRENT_DATE + ((i % 30)) +FROM brands b +CROSS JOIN generate_series(1, 200) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 11. Time tracking infrastructure + logs +-- ============================================================================ +INSERT INTO time_tracking_workers (id, brand_id, name, role, pin, lang, active) +SELECT gen_random_uuid(), b.id, 'Worker ' || i, CASE (i%2) WHEN 0 THEN 'worker' ELSE 'time_admin' END, lpad((1000+i)::text, 4, '0'), 'en', true +FROM brands b +CROSS JOIN generate_series(1, 5) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO time_tracking_tasks (id, brand_id, name, unit, sort_order, active) +SELECT gen_random_uuid(), b.id, 'Task ' || i, CASE (i%3) WHEN 0 THEN 'hours' WHEN 1 THEN 'pieces' ELSE 'units' END, i, true +FROM brands b +CROSS JOIN generate_series(1, 8) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO time_tracking_logs (brand_id, worker_id, task_id, task_name, clock_in, clock_out, lunch_break_minutes, notes) +SELECT + b.id, + (SELECT id FROM time_tracking_workers WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (SELECT id FROM time_tracking_tasks WHERE brand_id = b.id ORDER BY random() LIMIT 1), + 'Task ' || (1 + (i % 8)), + (NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval), + (NOW() - ((i % 30) || ' days')::interval - ((i % 8) || ' hours')::interval + interval '90 minutes'), + CASE WHEN i % 4 = 0 THEN 30 ELSE 0 END, + 'QA generated log entry #' || i +FROM brands b +CROSS JOIN generate_series(1, 200) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 12. Water log infrastructure + entries +-- ============================================================================ +INSERT INTO water_headgates (id, brand_id, name, max_flow_gpm, unit, status, active) +SELECT gen_random_uuid(), b.id, 'Headgate ' || i, 1000 + i * 100, 'GPM', 'open', true +FROM brands b +CROSS JOIN generate_series(1, 5) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO water_irrigators (id, brand_id, name, pin_hash, language_preference, role, phone, active) +SELECT gen_random_uuid(), b.id, 'Irrigator ' || i, 'placeholder-pin-hash', 'en', 'irrigator', '+15550100000', true +FROM brands b +CROSS JOIN generate_series(1, 5) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, logged_at, logged_by, method, total_gallons, notes) +SELECT + b.id, + (SELECT id FROM water_headgates WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (SELECT id FROM water_irrigators WHERE brand_id = b.id ORDER BY random() LIMIT 1), + (50 + (i * 7) % 200)::numeric, + 'GPM', + NOW() - ((i % 14) || ' days')::interval, + 'aaaa1111-1111-1111-1111-111111111111', + 'manual', + (1000 + (i * 23) % 5000)::numeric, + 'Auto-generated water log #' || i +FROM brands b +CROSS JOIN generate_series(1, 100) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- 13. Audit log entries +-- ============================================================================ +INSERT INTO audit_logs (brand_id, user_id, action, entity_type, entity_id, details, ip_address, created_at) +SELECT + b.id, + 'aaaa1111-1111-1111-1111-111111111111', + (ARRAY['create','update','delete','view','export'])[1 + (i % 5)], + (ARRAY['products','orders','stops','customers'])[1 + (i % 4)], + gen_random_uuid(), + jsonb_build_object('qa', true, 'iteration', i), + '10.0.0.' || (1 + (i % 254)), + NOW() - ((i % 90) || ' days')::interval +FROM brands b +CROSS JOIN generate_series(1, 50) AS i +WHERE b.id IN ('11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222') +ON CONFLICT DO NOTHING; + +COMMIT; + +-- ============================================================================ +-- Summary +-- ============================================================================ +SELECT 'products' AS tbl, COUNT(*) FROM products UNION ALL +SELECT 'stops', COUNT(*) FROM stops UNION ALL +SELECT 'customers', COUNT(*) FROM customers UNION ALL +SELECT 'orders', COUNT(*) FROM orders UNION ALL +SELECT 'order_items', COUNT(*) FROM order_items UNION ALL +SELECT 'wholesale_customers', COUNT(*) FROM wholesale_customers UNION ALL +SELECT 'wholesale_orders', COUNT(*) FROM wholesale_orders UNION ALL +SELECT 'comm_templates', COUNT(*) FROM communication_templates UNION ALL +SELECT 'comm_campaigns', COUNT(*) FROM communication_campaigns UNION ALL +SELECT 'comm_contacts', COUNT(*) FROM communication_contacts UNION ALL +SELECT 'comm_message_logs', COUNT(*) FROM communication_message_logs UNION ALL +SELECT 'time_tracking_logs', COUNT(*) FROM time_tracking_logs UNION ALL +SELECT 'water_log_entries', COUNT(*) FROM water_log_entries UNION ALL +SELECT 'audit_logs', COUNT(*) FROM audit_logs UNION ALL +SELECT 'brands', COUNT(*) FROM brands UNION ALL +SELECT 'admin_users', COUNT(*) FROM admin_users +ORDER BY tbl; \ No newline at end of file diff --git a/db/seeds/2026-tuxedo-tour-stops.sql b/db/seeds/2026-tuxedo-tour-stops.sql index 08a959f..f6003f8 100644 --- a/db/seeds/2026-tuxedo-tour-stops.sql +++ b/db/seeds/2026-tuxedo-tour-stops.sql @@ -2,6 +2,7 @@ -- Stops: 269 | Locations: 40 -- Preferred: npx tsx scripts/import-tuxedo-stops.ts -- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql +-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql BEGIN; diff --git a/docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md b/docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md new file mode 100644 index 0000000..e633938 --- /dev/null +++ b/docs/qa/audit-2026-06-25/ACCEPTANCE-CRITERIA.md @@ -0,0 +1,671 @@ +# Acceptance Criteria — 2026-06-25 + +> Format per item: **AC-NN.SS** (module.section) — Given/When/Then or assertion. Edge cases follow each AC block prefixed `EC-NN.SS.x`. + +## Risk tier definitions + +- **C — Critical**: any data loss, RBAC bypass, payment error, auth failure, or cross-tenant data leak. **Must** pass before sign-off. +- **H — High**: core CRUD flows used daily by admin/brand_admin. Pass-blocking. +- **M — Medium**: secondary features (filters, exports, analytics). Pass-blocking for release quality, acceptable for hot-fix. +- **L — Low**: cosmetics, marketing pages, static content. Best-effort. + +--- + +## ROLES & IDENTITY (C) + +### AC-R1 — dev_session cookie impersonates roles in dev mode +- **Given** NODE_ENV !== "production" +- **When** request carries `Cookie: dev_session=platform_admin` +- **Then** `getAdminUser()` returns `{ role: 'platform_admin', brand_id: null, brandIds: [...], email: 'qa-platform@...' }` +- **And** every `/admin/*` route renders without `` + +EC-R1: +- EC-R1.1: invalid value (`dev_session=hacker`) → returns null (no impersonation) +- EC-R1.2: missing cookie → null → `/admin/*` shows Access Denied +- EC-R1.3: NODE_ENV=production → dev_session is ignored, real Neon Auth required + +### AC-R2 — RBAC enforcement +- **Given** admin user with `can_manage_orders=false` +- **When** visiting `/admin/orders/[id]` +- **Then** redirect to `/admin/pickup` +- **And** `/admin/orders` shows Access Denied or redirects similarly + +EC-R2: +- EC-R2.1: any permission flag `can_manage_X=false` enforces redirect on `/admin/X` +- EC-R2.2: all flags `true` → no redirects +- EC-R2.3: brand_admin without brand link → null user → Access Denied + +### AC-R3 — Tenant scoping +- **Given** brand_admin of brand A +- **When** calling RPCs with `p_brand_id=brand_A_id` +- **Then** only brand A rows are returned +- **When** calling with `p_brand_id=brand_B_id` +- **Then** zero rows or 403/empty depending on the action + +EC-R3: +- EC-R3.1: platform_admin with `p_brand_id=null` returns all brands +- EC-R3.2: brand_admin calling with `p_brand_id=null` falls back to their `brand_id` +- EC-R3.3: brand_admin calling with `p_brand_id=other_brand_id` → zero/empty + +--- + +## ADMIN SHELL (C) + +### AC-A1 — `/admin/page.tsx` redirects to `/admin/v2` +- **Then** 307 → `/admin/v2` + +### AC-A2 — `/admin/v2` dashboard renders for platform_admin +- **Then** shows "Today" heading + stat cards + stops + orders +- **And** no Access Denied + +EC-A2: +- EC-A2.1: 0 stops today → "No stops scheduled today" empty state +- EC-A2.2: 0 pending orders → empty state +- EC-A2.3: activeBrandId null → "No brand selected" empty state + +### AC-A3 — AdminSidebar nav links navigate correctly +- **Then** every Workspace/Operations/Communications/Growth/Tracking/Insights/Settings link navigates to its target route without error + +EC-A3: +- EC-A3.1: Tracking → Water Log hidden when `enabledAddons["water_log"]=false` +- EC-A3.2: Tracking → Route Trace hidden when `enabledAddons["route_trace"]=false` +- EC-A3.3: `requiresPlatformAdmin` items hidden for non-platform users +- EC-A3.4: mobile menu closes on link click +- EC-A3.5: ESC closes mobile menu +- EC-A3.6: focus moves to close button when mobile menu opens +- EC-A3.7: arrow-key navigation loops top-to-bottom-to-top + +### AC-A4 — MobileTabBar navigates and shows active state +- **Then** clicking each tab navigates + highlights active tab matching `pathname` + +EC-A4: +- EC-A4.1: clicking More opens MoreSheet +- EC-A4.2: clicking a MoreSheet link closes sheet and navigates + +### AC-A5 — CommandPalette opens on Cmd/Ctrl+K +- **Then** modal appears, search input focused + +EC-A5: +- EC-A5.1: ↑↓ navigation, Enter selects, ESC closes +- EC-A5.2: body scroll locked while open +- EC-A5.3: backdrop click closes +- EC-A5.4: empty results → "No results for 'query'" +- EC-A5.5: navigate via result updates URL and closes palette + +### AC-A6 — BrandSelector switches active brand +- **Then** selecting a brand sets cookie + refreshes page; `BrandSelector` reflects new active + +EC-A6: +- EC-A6.1: "All brands" option visible only to platform_admin +- EC-A6.2: multi-brand admin → "multi" badge +- EC-A6.3: outside click closes +- EC-A6.4: ESC closes + +### AC-A7 — OfflineBanner shows when offline or pending +- **Then** offline → danger-soft banner; pending sync → warning-soft banner +- **When** online + 0 pending → banner hidden + +EC-A7: +- EC-A7.1: pre-mount → null (no hydration mismatch) + +--- + +## ORDERS (C) + +### AC-M1.1 — `/admin/orders` legacy list loads +- **Then** status tabs (all/pending/picked_up) render with counts +- **And** table shows orders sorted by placed_at desc + +EC-M1.1: +- EC-M1.1.1: empty state when 0 orders → "No orders yet" + Create CTA +- EC-M1.1.2: filter by status → list filters correctly +- EC-M1.1.3: search by customer name → matches substring +- EC-M1.1.4: search by phone → matches digits +- EC-M1.1.5: search by order id prefix → matches +- EC-M1.1.6: pagination next/prev changes page +- EC-M1.1.7: stop filter multi-select → list filtered by selected stops +- EC-M1.1.8: clearing filters shows all orders +- EC-M1.1.9: bulk select + Mark All Picked Up → all selected marked, toast shows counts +- EC-M1.1.10: partial bulk failure → some succeed, others revert, error toast +- EC-M1.1.11: `?new=true` opens modal on mount +- EC-M1.1.12: row click navigates to `/admin/orders/[id]` + +### AC-M1.2 — `/admin/orders/new` modal +- **Then** modal opens with required: customer name; optional: email, phone, stop +- **And** items table with at least 1 row + +EC-M1.2: +- EC-M1.2.1: empty customer name → submission blocked +- EC-M1.2.2: 0 items → Create Order disabled +- EC-M1.2.3: invalid price (< 0 or non-numeric) → submission blocked +- EC-M1.2.4: qty < 1 → blocked +- EC-M1.2.5: success → full-page reload to `/admin/orders`, modal closes +- EC-M1.2.6: server error → red error banner inside modal +- EC-M1.2.7: ESC closes modal +- EC-M1.2.8: backdrop click closes modal + +### AC-M1.3 — `/admin/orders/[id]` detail + edit +- **Then** shows customer, stop, items, totals, payment section, edit form + +EC-M1.3: +- EC-M1.3.1: invalid id → "Order not found" +- EC-M1.3.2: empty customer name on save → "Customer name is required" +- EC-M1.3.3: negative discount → "Discount cannot be negative" +- EC-M1.3.4: remove item → marked removed, deleted on save +- EC-M1.3.5: save success → toast + page refreshes +- EC-M1.3.6: status set to fulfilled (not exposed in UI) → should not be reachable +- EC-M1.3.7: pickup toggle: not picked up → "Mark Picked Up"; picked up → button hidden +- EC-M1.3.8: refund amount = 0 → button disabled +- EC-M1.3.9: refund amount > remaining balance → validation error +- EC-M1.3.10: refund amount = remaining balance → success + +### AC-M1.4 — `/admin/v2/orders` mobile list +- **Then** cards sorted; status filter via `?status=` + +EC-M1.4: +- EC-M1.4.1: 0 orders → "No orders yet" +- EC-M1.4.2: status=placed → only placed orders shown +- EC-M1.4.3: card click → /admin/v2/orders/[id] + +### AC-M1.5 — `/admin/v2/orders/[id]` mobile detail +- **Then** shows timeline + sticky action bar with status buttons + +EC-M1.5: +- EC-M1.5.1: status transitions: placed → ready → picked-up; cancel from any state +- EC-M1.5.2: invalid id → notFound() + +--- + +## STOPS (H) + +### AC-M2.1 — `/admin/stops` list +- **Then** table sorted by date asc; tabs filter by status + +EC-M2.1: +- EC-M2.1.1: search matches city/location substring +- EC-M2.1.2: Add Stop modal opens +- EC-M2.1.3: Upload Schedule modal opens +- EC-M2.1.4: Edit → /admin/stops/[id] +- EC-M2.1.5: Publish (draft only) → status changes to active, refreshes +- EC-M2.1.6: Duplicate → /admin/stops/new?duplicate={id} +- EC-M2.1.7: Delete → confirm popover → confirm → row removed + +### AC-M2.2 — `/admin/stops/new` form +- **Then** required: city/state/location/date/time/brand + +EC-M2.2: +- EC-M2.2.1: missing required field → red border + helper +- EC-M2.2.2: `?duplicate={id}` pre-fills from existing stop +- EC-M2.2.3: brand selector hidden for non-platform_admin + +### AC-M2.3 — AddStopModal +- **Then** opens with autofocus on city + +EC-M2.3: +- EC-M2.3.1: missing city/state/location/date → submission blocked +- EC-M2.3.2: state uppercased on input +- EC-M2.3.3: state max 2 chars +- EC-M2.3.4: ESC closes +- EC-M2.3.5: success → modal closes + list refreshes + +### AC-M2.4 — LocationsTab +- **Then** table with search + status tabs + pagination + +EC-M2.4: +- EC-M2.4.1: Add Venue modal opens +- EC-M2.4.2: Edit modal opens with pre-filled fields +- EC-M2.4.3: Delete with confirm → row removed +- EC-M2.4.4: search matches name/address/city/contact_name + +### AC-M2.5 — `/admin/v2/stops` mobile +- **Then** cards bucketed by Morning/Afternoon/Evening/Anytime + +EC-M2.5: +- EC-M2.5.1: `?date=YYYY-MM-DD` filters +- EC-M2.5.2: invalid date → empty or error +- EC-M2.5.3: 0 stops → "No stops scheduled" +- EC-M2.5.4: card with address → maps link works + +--- + +## PRODUCTS (H) + +### AC-M3.1 — `/admin/products` legacy list +- **Then** shows all brand-scoped products + +EC-M3.1: +- EC-M3.1.1: 0 products → empty state +- EC-M3.1.2: platform_admin sees products from all brands +- EC-M3.1.3: brand_admin sees only their brand + +### AC-M3.2 — `/admin/products/new` form +- **Then** name required, type/brand/taxable/pickup_type/active selects + +EC-M3.2: +- EC-M3.2.1: missing name → blocked +- EC-M3.2.2: image upload: drag-drop or click → preview +- EC-M3.2.3: oversized image (>5MB) → error +- EC-M3.2.4: wrong type → error +- EC-M3.2.5: success → /admin/products +- EC-M3.2.6: brand locked for non-platform_admin + +### AC-M3.3 — `/admin/products/import` CSV +- **Then** preview table shows parsed rows + +EC-M3.3: +- EC-M3.3.1: malformed CSV → parse errors listed +- EC-M3.3.2: missing brand ID → Import disabled +- EC-M3.3.3: success → counts panel + +### AC-M3.4 — `/admin/v2/products` mobile +- **Then** cards with status pill + StockAdjustButton + +EC-M3.4: +- EC-M3.4.1: `?filter=in-stock` → only products with inventory > 0 +- EC-M3.4.2: `?filter=out` → only inventory = 0 +- EC-M3.4.3: `?filter=hidden` → only active=false +- EC-M3.4.4: StockAdjustButton increments/decrements inventory + +--- + +## COMMUNICATIONS (H) + +### AC-M6.1 — Campaigns list + create + edit +- **Then** list with type/status filters + +EC-M6.1: +- EC-M6.1.1: New Campaign → name + type required → Create +- EC-M6.1.2: Save Draft → status=draft, no send +- EC-M6.1.3: Schedule Campaign with future datetime → status=scheduled +- EC-M6.1.4: Send Campaign → calls sendCampaign → status=sent +- EC-M6.1.5: Schedule with past datetime → blocked (datetime-local min = now) +- EC-M6.1.6: missing subject/body → blocked +- EC-M6.1.7: previewAudience → shows count + sample emails + +### AC-M6.2 — Templates list + edit +- **Then** edit fields: subject, body_text, body_html, template_type, campaign_type + +EC-M6.2: +- EC-M6.2.1: missing subject → blocked (subject is NOT NULL) +- EC-M6.2.2: success → list refreshes + +### AC-M6.3 — Contacts list +- **Then** search + source filter + pagination + +EC-M6.3: +- EC-M6.3.1: delete with confirm → row removed +- EC-M6.3.2: Export CSV → file downloads +- EC-M6.3.3: pagination prev/next + +### AC-M6.4 — Message logs +- **Then** status filter + search + pagination (1-5 pages) + +EC-M6.4: +- EC-M6.4.1: filter by status=failed → only failed shown +- EC-M6.4.2: stats cards reflect filtered count + +### AC-M6.5 — Abandoned Carts +- **Then** stats + filter (all/active/recovered) + pagination + +EC-M6.5: +- EC-M6.5.1: View → detail modal with items + timestamps +- EC-M6.5.2: Close → status=manually_closed +- EC-M6.5.3: Resend → calls resendAbandonedCartEmail + +### AC-M6.6 — Communication Settings +- **Then** senderEmail/senderName/replyTo/footerHtml form + +EC-M6.6: +- EC-M6.6.1: invalid email → error +- EC-M6.6.2: `{unsubscribe_url}` placeholder preserved in footerHtml + +### AC-M6.7 — ContactImportForm (multi-step) +- **Then** drag/drop or click → preview → import + +EC-M6.7: +- EC-M6.7.1: file >5MB → bucket path +- EC-M6.7.2: missing required mapping → blocked +- EC-M6.7.3: success → counts panel + +--- + +## WHOLESALE (H) + +### AC-M7.1 — Wholesale dashboard loads +- **Then** shows tabs for Customers / Orders / Pricing / Settings + +EC-M7.1: +- EC-M7.1.1: 0 customers → empty state +- EC-M7.1.2: 0 orders → empty state + +### AC-M7.2 — Wholesale Customers +- **Then** list with search + status filter + +EC-M7.2: +- EC-M7.2.1: bulk "Send Price Sheet" → calls sendPriceSheetToCustomer N times + +### AC-M7.3 — Wholesale Orders +- **Then** list with status filter + +EC-M7.3: +- EC-M7.3.1: bulk Fulfill → multiple fulfillments +- EC-M7.3.2: bulk Deposit → multiple deposits + +### AC-M7.4 — Wholesale Settings +- **Then** form saves via updateWholesaleSettings + +EC-M7.4: +- EC-M7.4.1: invalid email → error +- EC-M7.4.2: min_order_amount < 0 → validation + +--- + +## SETTINGS (H) + +### AC-M8.1 — BrandSettingsForm +- **Then** all sections save atomically + +EC-M8.1: +- EC-M8.1.1: logo upload → preview + URL stored +- EC-M8.1.2: nexus state pill: add via Enter or comma → chip appears +- EC-M8.1.3: nexus state pill: click ✕ → chip removed +- EC-M8.1.4: state field: lowercased input → uppercased display +- EC-M8.1.5: brand colors: text input + color picker stay in sync +- EC-M8.1.6: feature toggles: each toggle saves independently + +### AC-M8.2 — BrandFeatureCards +- **Then** each card shows status + Enable/Disable + +EC-M8.2: +- EC-M8.2.1: Enable → confirmation modal → success toast + card shows Active +- EC-M8.2.2: Disable → optimistic toggle → rollback on failure +- EC-M8.2.3: "Open →" link visible only when enabled + adminRoute set + +### AC-M8.3 — CreateUserModal +- **Then** email/password (≥6) required + +EC-M8.3: +- EC-M8.3.1: invalid email (no @) → blocked +- EC-M8.3.2: password < 6 → blocked +- EC-M8.3.3: missing brand for brand_admin → blocked +- EC-M8.3.4: success → temp password surfaced + email-sent status +- EC-M8.3.5: Copy button copies temp password +- EC-M8.3.6: role options depend on caller role + +### AC-M8.4 — PaymentSettingsForm +- **Then** provider radio + Stripe/Square OAuth buttons + Square Location ID + +EC-M8.4: +- EC-M8.4.1: Square Location ID without `L` prefix → blocked +- EC-M8.4.2: Save disabled when not dirty +- EC-M8.4.3: Stripe OAuth flow → return with ?stripe_connected=true → URL param stripped +- EC-M8.4.4: Square OAuth flow → return with ?square_connected=true → URL param stripped +- EC-M8.4.5: Sync Products/Orders/All Now → shows result banner + +### AC-M8.5 — AdvancedSettingsClient tabs +- **Then** tabs render Integrations / AI / Square / Shipping / Webhooks / Payments + +EC-M8.5: +- EC-M8.5.1: Integrations: Resend + Twilio save independently +- EC-M8.5.2: AI Tools: provider selection changes default model +- EC-M8.5.3: AI Tools: API key required for Test +- EC-M8.5.4: Square Sync: App ID + Access Token required for Test +- EC-M8.5.5: Shipping: Test Connection (simulated) shows success after 1s +- EC-M8.5.6: Webhooks tab shows "Coming Soon" +- EC-M8.5.7: Payments: Connect with Stripe → redirects to Stripe + +--- + +## ANALYTICS (M) + +### AC-M9.1 — AnalyticsDashboard loads +- **Then** KPI cards + revenue chart + top products + recent orders + customer growth + funnel + +EC-M9.1: +- EC-M9.1.1: period selector change → chart re-renders (visual only, no refetch) +- EC-M9.1.2: Refresh → fetches all +- EC-M9.1.3: Export Report → no-op +- EC-M9.1.4: 0 revenue → "No revenue data available yet" +- EC-M9.1.5: error → retry button works + +--- + +## TIME TRACKING (H) + +### AC-M11.1 — TimeTrackingAdminPanel tabs +- **Then** Summary / Workers / Tasks / Logs / Settings + +EC-M11.1: +- EC-M11.1.1: 0 workers → empty +- EC-M11.1.2: Add Worker → name/role/language/active → Create +- EC-M11.1.3: Reset PIN → calls resetTimeWorkerPin +- EC-M11.1.4: Delete Worker → confirm → removed +- EC-M11.1.5: Logs filter by worker + task + date range → list updates +- EC-M11.1.6: Logs pagination 50/page +- EC-M11.1.7: Export → CSV downloads from /api/time-tracking/export + +--- + +## WATER LOG (H) + +### AC-M11.2 — WaterLogAdminPanel +- **Then** Add Headgate inline + Add User inline + filter chips + Export CSV + +EC-M11.2: +- EC-M11.2.1: missing name → blocked +- EC-M11.2.2: PIN auto-generated on user create → PinBanner shows +- EC-M11.2.3: Rotate token → token changes +- EC-M11.2.4: Filter chips → entries filtered +- EC-M11.2.5: Export CSV → downloads +- EC-M11.2.6: Preview Report → window.alert + +### AC-M11.3 — HeadgatesManager bulk QR +- **Then** select multiple + Print → new window with QR sheet + +EC-M11.3: +- EC-M11.3.1: 0 selected → Print disabled +- EC-M11.3.2: regenerate token → existing printed QRs invalidated +- EC-M11.3.3: per-headgate QR Download PNG + +### AC-M11.4 — Water Admin Settings +- **Then** toggles + session duration slider + alert phone + +EC-M11.4: +- EC-M11.4.1: Regenerate PIN → confirm → PIN revealed +- EC-M11.4.2: session duration 1-168 hours only +- EC-M11.4.3: Save Settings → success banner + +--- + +## ROUTE TRACE (M) + +### AC-M11.5 — RouteTracePage loads +- **Then** shell renders with stat cards + lot list + +EC-M11.5: +- EC-M11.5.1: missing feature flag → redirect +- EC-M11.5.2: lot detail → timeline + orders + +--- + +## LAUNCH CHECKLIST (L — known cosmetic) + +### AC-M11.6 — LaunchChecklist renders +- **Then** 8 categories × 4 tasks + +EC-M11.6: +- EC-M11.6.1: "Mark All Complete" → no-op (documented limitation) +- EC-M11.6.2: "Export PDF" → no-op +- EC-M11.6.3: Per-task checkbox → no-op +- EC-M11.6.4: "Go →" links work + +--- + +## IMPORT CENTER (M) + +### AC-M11.7 — ImportCenterClient wizard +- **Then** Upload → Analyze → Preview → Import + +EC-M11.7: +- EC-M11.7.1: drag/drop or click upload +- EC-M11.7.2: file >10MB → blocked +- EC-M11.7.3: AI detection confidence <80% → type override buttons shown +- EC-M11.7.4: column mapping dropdowns +- EC-M11.7.5: Import disabled when type=unknown +- EC-M11.7.6: success → counts + "View" link + +--- + +## PICKUP (C) + +### AC-M4.1 — DriverPickupPanel +- **Then** pending orders + picked-up orders collapsible + +EC-M4.1: +- EC-M4.1.1: Pick Up → calls markPickupComplete → toast 3s +- EC-M4.1.2: stop filter → list filtered +- EC-M4.1.3: search → matches name/phone/order id prefix +- EC-M4.1.4: 0 pending → empty state +- EC-M4.1.5: picked-up older than 72h → hidden + +--- + +## SHIPPING (C) + +### AC-M5.1 — ShippingFulfillmentPanel +- **Then** shipping orders list + +EC-M5.1: +- EC-M5.1.1: 0 shipping orders → empty +- EC-M5.1.2: filter by status + +--- + +## PUBLIC/AUTH (H) + +### AC-P1 — Login page +- **Then** email + password submit to /api/auth/sign-in + +EC-P1: +- EC-P1.1: missing email → HTML5 validation blocks submit +- EC-P1.2: missing password → blocked +- EC-P1.3: invalid credentials → error banner +- EC-P1.4: dev_session=platform_admin → sets cookie and redirects /admin +- EC-P1.5: Google button disabled while loading +- EC-P1.6: Forgot? link → /forgot-password (or /api/forgot-password?) + +### AC-P2 — Change password +- **Then** new password + confirm match + ≥8 chars + +EC-P2: +- EC-P2.1: passwords don't match → error +- EC-P2.2: <8 chars → error +- EC-P2.3: success → /admin +- EC-P2.4: "Sign out instead" → /logout + +### AC-P3 — Reset password +- **Then** new password + confirm + +EC-P3: +- EC-P3.1: same as AC-P2 + +### AC-P4 — Logout +- **Then** spinner → /api/auth/sign-out → /login + +### AC-P5 — Maintenance splash +- **Then** no controls, only mailto + /contact links + +### AC-P6 — Homepage +- **Then** hero + CTA links + +### AC-P7 — Pricing +- **Then** BillingToggle, FAQ accordion, Compare table expand + +EC-P7: +- EC-P7.1: Monthly ↔ Annual swap content +- EC-P7.2: FAQ accordion: each opens/closes independently +- EC-P7.3: Compare table expand/collapse + +### AC-P8 — Contact +- **Then** form simulated submit (setTimeout) + +EC-P8: +- EC-P8.1: missing required field → blocked +- EC-P8.2: success card replaces form + +### AC-P9 — Blog +- **Then** static + decorative newsletter form + +### AC-P10 — Changelog +- **Then** static timeline + RSS link + +### AC-P11 — Roadmap +- **Then** 3-column + Suggestion form (no submit) + Upvote buttons (no handlers) + +EC-P11: +- EC-P11.1: form submit does nothing (reloads page) +- EC-P11.2: upvote buttons no-op + +### AC-P12 — Security +- **Then** static + mailto + +### AC-P13 — Privacy / Terms +- **Then** static text + +### AC-P14 — Brands showcase +- **Then** per-brand visit links + +### AC-P15 — Waitlist +- **Then** form posts to /api/waitlist + +EC-P15: +- EC-P15.1: missing email → blocked +- EC-P15.2: success card replaces form +- EC-P15.3: server error → inline error + +### AC-P16 — Cart +- **Then** items list + qty controls + stop picker + +EC-P16: +- EC-P16.1: − button decreases qty; at qty=1, "Remove" is the only path +- EC-P16.2: + increases qty +- EC-P16.3: Remove → item disappears +- EC-P16.4: stop mismatch → "Choose Correct Stop" alert +- EC-P16.5: incompatible items → "Remove Incompatible Items First" CTA +- EC-P16.6: empty cart → "Cart is Empty" CTA +- EC-P16.7: availability error → "Remove Incompatible Items First" +- EC-P16.8: ESC closes stop picker +- EC-P16.9: backdrop closes stop picker + +### AC-P17 — Checkout +- **Then** customer details + stop select + shipping (if ship) + Stripe Express iframe + +EC-P17: +- EC-P17.1: missing email → blocked +- EC-P17.2: no stop + ship items → blocked (?) +- EC-P17.3: state field uppercased +- EC-P17.4: "Use secure hosted checkout" → Stripe redirect (uses placeholder key, will fail in QA) +- EC-P17.5: empty cart → "Back to storefront" link + +### AC-P18 — Auth API routes +- /api/auth/sign-in: 400/401/500 surfaces error +- /api/auth/sign-out: redirect /login +- /api/auth/forgot-password: always success (anti-enumeration) +- /api/auth/reset-password: 400 on <8 chars +- /api/auth/change-password: auth required + +--- + +## Known cosmetic-only behaviors (NOT bugs) + +- `/admin/launch-checklist` checkboxes, "Mark All Complete", "Export PDF" are all no-ops (M11.6) +- `/admin/analytics` "Export Report" is no-op (M9.1 EC-3) +- `/admin/analytics` period selector doesn't refetch (M9.1 EC-1) +- `/admin/me` profile + email change forms always show "temporarily unavailable" (AdminMeClient) +- `/admin/advanced/shipping` and `/admin/settings/square-sync/advanced` are static mocks with setTimeout simulators +- `/admin/communications/compose`, `/contacts`, `/segments`, `/logs`, `/analytics` all redirect to `?tab=` query (preserved backward compat) +- `/blog/newsletter` and `/blog/download` buttons have no handlers +- `/roadmap/suggestion` form and upvotes have no handlers +- `/admin/orders/[id]` does NOT expose "fulfilled" status (deliberate) + +These are **documented limitations**, not bugs. Any change to them should be reviewed for product intent first. \ No newline at end of file diff --git a/docs/qa/audit-2026-06-25/ENV-DIFF.md b/docs/qa/audit-2026-06-25/ENV-DIFF.md new file mode 100644 index 0000000..fc1978f --- /dev/null +++ b/docs/qa/audit-2026-06-25/ENV-DIFF.md @@ -0,0 +1,61 @@ +# Environment Differences from Production + +This audit runs against a local Dockerized Postgres + dev server, not production. Below are the differences an auditor should know about before trusting any finding. + +## 1. Neon Auth is stubbed + +Production uses Neon Auth (Better Auth) to manage `neon_auth.user`. In this audit, that schema/table is a minimal local stub created by `db/migrations/0000_qa_neon_auth_stub.sql`: + +```sql +CREATE SCHEMA IF NOT EXISTS neon_auth; +CREATE TABLE IF NOT EXISTS neon_auth.user ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT UNIQUE NOT NULL, + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Authentication is **not exercised via the real sign-in flow**. Instead, the `dev_session` cookie impersonates roles: + +| Cookie value | Impersonates | +|---|---| +| `dev_session=platform_admin` | Cross-brand admin (`role='platform_admin'`, `brand_id=null`) | +| `dev_session=brand_admin` | Single-brand admin | +| `dev_session=store_employee` | Limited-scope employee | + +This is gated by `process.env.NODE_ENV !== "production"`. Any bug found in the real Neon Auth flow (sign-in, password reset, MFA, OAuth) **cannot** be reproduced here. + +## 2. Migration 0002 (`0002_admin_password.sql`) is marked applied but does not run + +The migration references a legacy `users` table that no longer exists in the current schema (the codebase migrated from Auth.js Credentials to Neon Auth). The migration is recorded in `_migrations` so the runner skips it, and the column it would have added (`users.password_hash`) is absent. **This is a separate latent issue** worth a follow-up PR: 0002 should be removed from the migrations directory or rewritten as a no-op. + +## 3. New migration `0000_qa_neon_auth_stub.sql` (audit-only) + +Added to make migrations runnable without Neon Auth. Should not be applied to production (it would conflict with the real `neon_auth.user` table). Move to a dev-only seed if reused. + +## 4. Seed file `db/seeds/2026-qa-audit-scale.sql` (audit-only) + +Replaces the broken `db/seed.ts`, which references removed columns (`brand_settings.brand_name`). Idempotent on a clean DB; re-runs without reset will duplicate rows in tables lacking unique constraints on natural keys (orders, order_items). + +## 5. Stripe / Resend / Square credentials are placeholders + +``` +STRIPE_SECRET_KEY=sk_test_placeholder_for_qa_audit +RESEND_API_KEY=re_placeholder_for_qa_audit +SQUARE_ACCESS_TOKEN=square_placeholder_for_qa_audit +``` + +Real network calls to those services would fail. The audit tests UI navigation, server actions that don't depend on real third-party responses, and DB-backed flows. Stripe checkout, real Resend sends, Square inventory sync — these are out of scope for this audit run. + +## 6. Database port + +The audit Postgres runs on `:5433`, not `:5432`. `:5432` is occupied by `n8n-postgres-1`. + +## 7. The "production-scale" data is sanitized + +No real customer PII, no real payment tokens, no real email addresses. All customer emails are `@routecomm.example` (RFC 2606 reserved). Phone numbers follow the +1-555-01xx-xxxx range reserved for fictional use. + +## 8. RBAC scope + +This audit tests **only** the `platform_admin` role. The QA users `qa-tuxedo@routecomm.example` and `qa-ird@routecomm.example` are seeded for follow-up brand-scoped testing but not exercised here. \ No newline at end of file diff --git a/docs/qa/audit-2026-06-25/INVENTORY.md b/docs/qa/audit-2026-06-25/INVENTORY.md new file mode 100644 index 0000000..f6bd0ba --- /dev/null +++ b/docs/qa/audit-2026-06-25/INVENTORY.md @@ -0,0 +1,333 @@ +# QA Audit Inventory — 2026-06-25 + +> Generated for [Loop 010 — full product evaluation](https://signals.forwardfuture.com/loop-library/loops/full-product-evaluation-loop/). +> Scope: Admin shell (all 19 modules) + public/auth pages, tested as `platform_admin` via `dev_session` cookie. +> Source: 5 parallel `explore` subagents enumerated every UI control, server action, state, and workflow. + +## Inventory organization + +- `R1`–`R4`: Role/identity assumptions +- `A1`–`A11`: Admin shell, layout, dashboard +- `M1`–`M11`: Admin modules (orders, stops, products, pickup, shipping, communications, wholesale, settings, analytics, users, import) +- `P1`–`P19`: Public + auth pages +- `T1`–`T6`: Cross-cutting patterns, design system, server-action catalog + +Each item: route → purpose → buttons → forms → modals → states → workflows → server actions. + +--- + +## R — Roles & identity + +### R1 — `dev_session` cookie impersonation +**File:** `src/lib/admin-permissions.ts` lines 36-39. +Gated by `NODE_ENV !== "production"`. Values: `platform_admin`, `brand_admin`, `store_employee`. Returns admin user via `buildDevAdmin(role)` bypassing Neon Auth. + +### R2 — Real Neon Auth (Better Auth) +**File:** `src/lib/auth.ts`. Email/password sign-in, password reset, OAuth via `signInWithGoogleAction`. Session via `getSession()`. **Not exercised** in this audit (auth is stubbed). + +### R3 — RBAC permission flags +**File:** `admin_users` table; flags: `can_manage_orders`, `can_manage_products`, `can_manage_stops`, `can_manage_customers`, `can_manage_wholesale`, `can_manage_billing`, `can_manage_settings`, `can_manage_water_log`, `can_manage_time_tracking`, `can_manage_route_trace`, `can_manage_reports`, `can_manage_communications`, `can_manage_pickup`, `can_manage_messages`, `can_manage_refunds`, `can_manage_users`, `active`. Default brand_admin = orders/products/stops/customers/pickup/messages/reports = `true`, rest `false`. + +### R4 — Tenant scoping (brand isolation) +- App-layer: `effectiveBrandId = brandId ?? adminUser.brand_id ?? null`; threaded through page → client. +- DB: `current_brand_id()` GUC + RLS policies on most tables (`brand_id = current_brand_id() OR is_platform_admin()`). +- Platform admin: `brand_id IS NULL`, sees all brands via `withPlatformAdmin()`. + +--- + +## A — Admin shell, layout, dashboard + +### A1 — `/admin/layout.tsx` +Server root layout. Gates access via `getAdminUser()`, redirects on `must_change_password`. Mounts: `AdminSidebar` (desktop), `MobileTabBar` (mobile via v2 layout), `CommandPalette`, `PWAInstallPrompt`, `ToastContainer`, `RouteAnnouncer`, `SmoothViewTransition`. Brand/brand-list/addons fetched with empty fallbacks on error. + +### A2 — `/admin/page.tsx` (legacy dashboard) +Redirects to `/admin/v2`. Two paths: `role === "store_employee"` → 2-card landing (Pickup Lookup + Wholesale); otherwise fetches `getDashboardStats()` + `getBillingOverview()` server-side, renders `DashboardClient`. + +### A3 — `/admin/v2/layout.tsx` + `/admin/v2/page.tsx` (mobile-first dashboard) +Mobile "Today" dashboard. Fetches summary/stops/orders in parallel. `Promise.all` failure → silent empty state. Renders `EmptyState` ("No brand selected" / "Nothing to show yet"). `pending_fulfillment > 0` → amber callout link `/admin/v2/orders?status=placed`. Top 5 today stops + top 5 pending orders. Pull-to-refresh wrapper. + +### A4 — `AdminShell.tsx` +Responsive shell: `useMediaQuery("(min-width: 1024px)")` swaps desktop sidebar vs mobile (`OfflineBanner` + `MobileTabBar`). Mounts `OfflineBanner` only on mobile. `pb-20` mobile bottom padding for tab bar. + +### A5 — `AdminSidebar.tsx` +Desktop nav + mobile slide-in. Nav groups: Workspace / Operations / Communications / Growth / Tracking / Insights / Settings. Brand selector pill. Sign-out button (`signOutAction`). Hamburger on ``. Sections: Operations (Pickup, Shipping, Reports, Analytics), Marketing (Communications, Sales), Settings (Settings, Advanced). Saves focus on open, restores on close. + +### A8 — `CommandPalette.tsx` +Cmd/Ctrl+K modal. Fuzzy search over static entry list (`./command-palette-data.ts`). Up to 8 results. ↑↓ to navigate, Enter to select, ESC closes. `body.style.overflow = "hidden"` while open. Honors `prefers-reduced-motion`. + +### A9 — `BrandSelector.tsx` +Pill dropdown for active brand. Calls `setActiveBrand(brandId)` server action, then `router.refresh()`. Outside-click + ESC close. "All brands" option for platform_admin only. Multi-brand badge. + +### A10 — `AdminAccessDenied.tsx` +Static card with "Go to Login" + "Return to homepage" links. + +### A11 — `OfflineBanner.tsx` +Sticky top banner: warning-soft when syncing, danger-soft when offline. Polls pendingCount every 1 s. Hidden pre-mount to avoid hydration mismatch. + +--- + +## M — Admin modules + +### M1 — Orders +- `/admin/orders` (legacy) → `AdminOrdersPanel`: KPI tiles, status tabs (all/pending/picked_up), search (name/phone/id), stop multi-filter dropdown, table with checkbox + bulk pickup, pagination (PAGE_SIZE 20), `+ New Order` modal. Modal fields: customer name (required), email (optional), phone (optional), stop (optional), items[product+qty+price+fulfillment]. Workflows: create→full-page-reload, single pickup (optimistic), bulk pickup (loop). +- `/admin/orders/[id]` → `OrderEditForm` + `OrderPaymentSection` + `OrderPickupAction`. Edit fields: items qty/price, discount (must be ≥ 0), customer name (required), email/phone, status (pending/confirmed/cancelled — **fulfilled not exposed**), pickup toggle, internal notes. Payment fields: processor (none/manual/stripe/square/cash/venmo/other), payment status, transaction id, refund amount (0 < x ≤ remaining balance) + reason. +- `/admin/orders/new` → redirect to `/admin/orders?new=true` (opens modal). +- `/admin/v2/orders` → card-style list, `?status=` filter (placed/ready/picked-up/cancelled), LIMIT 50, no pagination. +- `/admin/v2/orders/[id]` → mobile detail with `FulfillmentTimeline` + sticky action bar (`OrderActionButtons`). + +### M2 — Stops +- `/admin/stops` → `AdminStopsPanel`: status tabs (all/active/draft/inactive), search city/location, `Add Stop` modal, `Upload Schedule` modal, per-row Edit/Publish/Duplicate/Delete. Delete uses inline popover confirm. PAGE_SIZE 50. +- `/admin/stops/[id]` → product assignment + edit form + `MessageCustomersSection`. +- `/admin/stops/new` → inline `NewStopForm` (not modal). Fields: city/state/location/date/time (required), brand select, active, address/zip/cutoff (optional). Also supports `?duplicate={id}`. +- `/admin/v2/stops` → card list bucketed by Morning/Afternoon/Evening/Anytime, `?date=` filter, LIMIT 200. +- Modals: `AddStopModal`, `EditStopModal`, `AddLocationModal`, `EditLocationModal`, `LocationsTab`. + +### M3 — Products +- `/admin/products` → `ProductsClient` (legacy): list with brand scoping (platform_admin sees all). No client pagination. +- `/admin/products/new` → `NewProductForm`: name (required), description, price, type (pickup/shipping/both), brand (locked for non-platform_admin), taxable, pickup_type, active, image upload (drag/drop, 5MB cap, client-side resize to 1200px). +- `/admin/products/import` → CSV upload → `parseProductCSV` → preview → `importProductsBatch`. +- `/admin/v2/products` → mobile card list with `?filter=` (all/in-stock/low/out/hidden) + `StockAdjustButton` per card. + +### M4 — Pickup +- `/admin/pickup` → `DriverPickupPanel`. Pending orders (filter by stop + search), per-order `Pick Up` button (calls `markPickupComplete`). Picked-up section collapsible (last 72h). Toast `✓ Order picked up` 3s. + +### M5 — Shipping +- `/admin/shipping` → `ShippingFulfillmentPanel`. Pre-loaded via `getShippingOrders()`. (Details in deeper inspection.) +- `/admin/settings/shipping` (advanced subtab) → `AdvancedShipping`: static UI mock — no server action calls. Carrier select (fedex/ups/usps/dhl), account/api fields, eye toggle, **Test Connection is simulated (1s setTimeout), Save Settings is simulated (500ms setTimeout)**. + +### M6 — Communications (Harvest Reach) +Unified hub at `/admin/communications` with 8 tabs: +- **Campaigns**: list with type/status filters. `New Campaign` modal (name + campaignType). Edit panel: name, type, template, audience target (stop/zip_code/customer_history/all_customers), stop_id/dates, subject, body, schedule (now/later + datetime-local). Save Draft / Schedule Campaign / Send Campaign buttons. `upsertCampaign`, `deleteCampaign`, `getCampaignTemplates`, `getCommunicationSegments`, `previewCampaignAudience`, `sendCampaign` server actions. +- **Compose**: legacy redirect target. +- **Templates**: list + edit (template_type, subject, body_text, body_html, campaign_type). Server: `getCommunicationTemplates`, `getTemplateById`. +- **Contacts**: search + source filter, pagination (50/page, offset), per-row delete (confirm), export CSV (`exportContacts`). Bulk: none. Card layout on `` "Visit Store" + "View Stops". 3D-tilt parallax. + +### P15 — `/waitlist` +``. +- Fields: name (optional text), email (required), referral select (Google/Social/Friend/Event/Podcast/Blog/Other). +- Button: Join the Waitlist (spinner → success card). +- Server: `POST /api/waitlist`. +- States: idle, isSubmitting, error, success. + +### P16 — `/cart` +``. +- Per-item buttons: − (decrease qty), + (increase qty), Remove. +- Modal: "Choose Pickup Stop" (stop buttons + Cancel). +- Buttons: Continue to Checkout (state-gated: Cart is Empty / Select Pickup Stop First / Remove Incompatible Items First / Choose Correct Stop / Continue Shopping). +- States: empty, stop mismatch alert, incompatible items, availability error, loading stops. + +### P17 — `/checkout` +`` + Stripe Express iframe. +- Fields: customer_name (text), customer_email (required), customer_phone (tel optional), stop_id (select required when no stop), shipping_address/city/state (maxLength 2 upper)/postal_code (when ship items). +- Buttons: Change pickup stop, "Use secure hosted checkout →" (`POST createRetailStripeCheckoutSession`), `` Back to storefront. +- States: empty cart, hostedLoading, hostedError, hasPickup/hasShed/hasStop/hasShip branching. + +### P18 — Auth API routes +| Route | Method | Behavior | +|---|---|---| +| `/api/auth/sign-in` | POST | `{success}` or 400/401/500 with `{error,message}`. | +| `/api/auth/sign-out` | POST | Server sign-out + redirect `/login`. | +| `/api/auth/forgot-password` | POST | Always `{success:true}` (anti-enumeration). Calls `requestPasswordReset`. | +| `/api/auth/reset-password` | POST | Min 8 chars. | +| `/api/auth/change-password` | POST | Auth-required; uses `setUserPassword`. | +| `/api/auth/[...nextauth]` | GET/POST | Neon Auth handler proxy. | + +### P19 — Other public pages +- `/water` — likely the field portal; brief enumeration. +- `/test-simple.html` — diagnostic HTML. +- `/api/feed/changelog.xml` — referenced but not in scope. + +--- + +## T — Cross-cutting patterns + +### T1 — Server-action catalog (top-level actions touched by the audited routes) +`getAdminUser`, `getAdminOrders`, `getAdminOrderDetail`, `markPickupComplete`, `createAdminOrder`, `updateOrder`, `updateOrderItem`, `deleteOrderItem`, `createRefund`, `getShippingOrders`, `createStop`, `updateStop`, `deleteStop`, `publishStop`, `assignProductToStop`, `unassignProductFromStop`, `createLocation`, `updateLocation`, `deleteLocation`, `createProduct`, `uploadProductImage`, `importProductsBatch`, `parseProductCSV`, `analyzeImport`, `executeImport`, `getAnalyticsMetrics`, `getRevenueChart`, `getTopProducts`, `getRecentOrders`, `getCustomerGrowth`, `getConversionFunnel`, `getCommunicationCampaigns`, `getCommunicationTemplates`, `getCommunicationContacts`, `getMessageLogs`, `getCommunicationSettings`, `upsertCommunicationSettings`, `getAbandonedCarts`, `manuallyCloseAbandonedCart`, `resendAbandonedCartEmail`, `sendStopBlast`, `previewContactImport`, `importContactsBatch`, `exportContacts`, `getWholesaleCustomers`, `getWholesaleOrders`, `getWholesaleSettings`, `updateWholesaleSettings`, `sendPriceSheetToCustomer`, `bulkFulfillOrders`, `recordWholesaleDeposit`, `createAdminUser`, `getAdminUsers`, `getBrands`, `getPaymentSettings`, `savePaymentSettings`, `syncSquareNow`, `getSyncLog`, `getResendCredentials`, `saveResendCredentials`, `testResendConnection`, `getTwilioCredentials`, `saveTwilioCredentials`, `testTwilioConnection`, `getStripeConnectStatus`, `createStripeConnectLink`, `refreshStripeConnectLink`, `disconnectStripeConnect`, `createStripeDashboardLink`, `getWaterIrrigators`, `getWaterHeadgatesAdmin`, `getWaterEntries`, `createWaterHeadgate`, `updateWaterHeadgate`, `deleteWaterHeadgate`, `regenerateHeadgateToken`, `createWaterUser`, `resetWaterIrrigatorPin`, `deleteWaterUser`, `getWaterAdminSettings`, `saveWaterAdminSettings`, `regenerateAdminPin`, `getTimeTrackingWorkers`, `getTimeTrackingTasks`, `getTimeTrackingSummary`, `getWorkerTimeLogs`, `createTimeWorker`, `updateTimeWorker`, `deleteTimeWorker`, `resetTimeWorkerPin`, `createTimeTask`, `updateTimeTask`, `deleteTimeTask`, `getRouteTraceStats`, `getRouteTraceLots`, `getRouteTraceLotDetail`, `getLotOrders`, `getHarvestLotsReadyToHaul`, `getFieldYieldSummary`, `getInventoryByCrop`, `getRecentLotEvents`, `signOutAction`, `signInWithGoogleAction`, `toggleBrandFeature`. + +### T2 — Server-action categories +- **CRUD**: orders, stops, products, customers, campaigns, templates, contacts, wholesale_*, time_tracking_*, water_log_*, harvest_lots. +- **Bulk**: `bulkFulfillOrders`, `sendPriceSheetToCustomer` (wholesale); `markPickupComplete` loop (orders). +- **Auth**: sign-in, sign-out, password reset/change, OAuth. +- **Integration side-effects**: `syncSquareNow`, `sendCampaign`, `sendStopBlast`, `savePaymentSettings` → Stripe OAuth URL generation. +- **Reads**: `get*` actions returning typed rows for page/server-component hydration. + +### T3 — Server-component vs client-component split +- Every route's `page.tsx` is `"use server"` and resolves `getAdminUser()` + brand + initial datasets in parallel. +- Heavy interactive panels (orders, stops, products, communications, settings, water-log) are `"use client"` components mounted by their page. +- v2 dashboard surfaces (`/admin/v2/*`) lean more on server components with `pool.query` directly. + +### T4 — Auth gating patterns +- Page-level: `if (!adminUser) redirect("/login")` or ``. +- Permission check: `if (!adminUser.can_manage_X) redirect("/admin/pickup")`. +- Brand-scope: `effectiveBrandId = brandId ?? adminUser.brand_id ?? firstBrandId`. +- Direct DB queries in pages use `getActiveBrandId()` helper; admin queries use `pool.query` with explicit `WHERE brand_id = $1` or `1=1` for platform_admin. + +### T5 — Modal inventory +| Modal | Triggered from | Server action | +|---|---|---| +| `GlassModal` shell | many | n/a (UI only) | +| `ElegantModal` shell | some | n/a | +| `AdminOrdersPanel` New Order modal | inline fixed overlay | `createAdminOrder` | +| `AddStopModal` | `AdminStopsPanel` Add | `createStop` | +| `EditStopModal` | inline | `createStop` / `updateStop` | +| `NewStopForm` (inline, not modal) | `/admin/stops/new` | `createStop` | +| `AddLocationModal` / `EditLocationModal` | `LocationsTab` | `createLocation` / `updateLocation` | +| `CreateUserModal` | settings Users | `createAdminUser` | +| `CampaignListPanel` New Campaign | `/admin/communications` | `upsertCampaign` | +| `AbandonedCartDashboard` detail | abandoned carts page | read-only | +| `BrandFeatureCards` Enable confirm | settings Add-ons | `toggleBrandFeature` | +| `HeadgatesManager` Edit/QR | headgates page | `updateWaterHeadgate`, `regenerateHeadgateToken` | +| Cart stop picker | `/cart` | n/a | +| `UpgradePlanModal` (lazy) | `DashboardClient` / `DashboardUpgradeButton` | n/a | +| `CommandPalette` | global Cmd+K | n/a | + +### T6 — Edge cases inventory (extracted for Phase 3) +- **Auth/role**: dev_session values, Neon Auth failures, must_change_password redirect, role-mismatched user without brand links. +- **Empty/zero states**: 0 brands, 0 stops, 0 orders, 0 contacts, 0 campaigns, 0 templates, 0 wholesale customers. +- **Long data**: very long product names, unicode (αβγ δεζ 🌿🍊 谢谢), special chars (`` or `<` breaks + out and becomes XSS. + → JSON.stringify does not HTML-escape, so a `` (or + `<`) in the data breaks out and becomes XSS. Use an + HTML-safe serializer (serialize-javascript, devalue) or + escape `<`, `>`, and `&`, or pass data via a JSON ` +`; + return new NextResponse(html, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); } diff --git a/src/app/api/stripe/oauth/callback/route.ts b/src/app/api/stripe/oauth/callback/route.ts index c427fb9..05f833b 100644 --- a/src/app/api/stripe/oauth/callback/route.ts +++ b/src/app/api/stripe/oauth/callback/route.ts @@ -1,101 +1,35 @@ import { NextResponse } from "next/server"; -import { getAdminUser } from "@/lib/admin-permissions"; -import { savePaymentSettings } from "@/actions/payments"; + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} export async function GET(req: Request) { - const adminUser = await getAdminUser(); - if (!adminUser || !adminUser.brand_id) { - return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", req.url)); - } - const url = new URL(req.url); - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - const state = url.searchParams.get("state"); + const code = url.searchParams.get("code") ?? ""; + const state = url.searchParams.get("state") ?? ""; + const error = url.searchParams.get("error") ?? ""; + const origin = url.origin; + const completeUrl = `${origin}/api/stripe/oauth/complete`; - // Handle error from Stripe - if (error) { - return NextResponse.redirect( - new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, req.url) - ); - } - - if (!code) { - return NextResponse.redirect( - new URL("/admin/settings/payments?stripe_error=no_code", req.url) - ); - } - - // Decode state to get brandId - let brandId = adminUser.brand_id; - if (state) { - try { - const decoded = JSON.parse(Buffer.from(state, "base64").toString()); - if (decoded.brandId) { - brandId = decoded.brandId; - } - } catch {} - } - - // Exchange code for access token - const clientId = process.env.STRIPE_CLIENT_ID; - const clientSecret = process.env.STRIPE_CLIENT_SECRET; - - if (!clientId || !clientSecret) { - return NextResponse.redirect( - new URL("/admin/settings/payments?stripe_error=oauth_not_configured", req.url) - ); - } - - try { - // Exchange authorization code for access token - const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - client_id: clientId, - client_secret: clientSecret, - }), - }); - - const tokenData = await tokenResponse.json(); - - if (tokenData.error) { - return NextResponse.redirect( - new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`, req.url) - ); - } - - // Save the Stripe credentials - const stripeUserId = tokenData.stripe_user_id; // This is the connected account ID - const accessToken = tokenData.access_token; - const publishableKey = tokenData.stripe_publishable_key; - - // Save to database via server action - const result = await savePaymentSettings({ - brandId, - provider: "stripe", - stripePublishableKey: publishableKey || undefined, - stripeSecretKey: accessToken, // Store access token as secret key - stripeUserId, // Store the connected account ID - }); - - if (result.success) { - return NextResponse.redirect( - new URL("/admin/settings/payments?stripe_connected=true", req.url) - ); - } else { - return NextResponse.redirect( - new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`, req.url) - ); - } - } catch (err) { - return NextResponse.redirect( - new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`, req.url) - ); - } -} \ No newline at end of file + const html = ` +Connecting Stripe… + +

Completing Stripe connection…

+
+ + + + +
+ +`; + return new NextResponse(html, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/src/app/api/time-tracking/export/route.ts b/src/app/api/time-tracking/export/route.ts index f22ff26..3dba183 100644 --- a/src/app/api/time-tracking/export/route.ts +++ b/src/app/api/time-tracking/export/route.ts @@ -77,20 +77,24 @@ export async function GET(req: NextRequest) { const allSettings: Awaited>[] = []; const allWorkers: Awaited>[] = []; - for (const brandId of brandIds) { - const [workers, settings, logs] = await Promise.all([ - getTimeTrackingWorkers(brandId), - getTimeTrackingSettings(brandId), - getWorkerTimeLogs(brandId, { - start: startDate || undefined, - end: endDate || undefined, - limit: 5000, - }), - ]); - allWorkers.push(workers); - allSettings.push(settings); - allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId }))); - } + await Promise.all(brandIds.map(async (brandId) => { + try { + const [workers, settings, logs] = await Promise.all([ + getTimeTrackingWorkers(brandId), + getTimeTrackingSettings(brandId), + getWorkerTimeLogs(brandId, { + start: startDate || undefined, + end: endDate || undefined, + limit: 5000, + }), + ]); + allWorkers.push(workers); + allSettings.push(settings); + allLogs = allLogs.concat((logs as LogEntry[]).map(l => ({ ...l, brandId }))); + } catch (e) { + console.error(e); + } + })); // Sort by clock_in allLogs.sort((a, b) => new Date(a.clock_in).getTime() - new Date(b.clock_in).getTime()); diff --git a/src/app/api/time-tracking/notify/route.ts b/src/app/api/time-tracking/notify/route.ts index b8fe3e8..47fb3da 100644 --- a/src/app/api/time-tracking/notify/route.ts +++ b/src/app/api/time-tracking/notify/route.ts @@ -242,9 +242,9 @@ export async function POST(req: NextRequest) { // ── Send SMS ──────────────────────────────────────────────────────────── if (smsNumbers.length > 0) { const smsBody = `[${brandName} Time Tracking] ALERT: ${workerName} — ${result.trigger_type?.replace("_", " ")}. Hours: ${dailyHours.toFixed(1)}d / ${weeklyHours.toFixed(1)}w. Check admin panel.`; - for (const num of smsNumbers) { - await sendTwilioSms(num, smsBody); - } + await Promise.all(smsNumbers.map(async (num) => { + try { await sendTwilioSms(num, smsBody); } catch (e) { console.error(e); } + })); } return NextResponse.json({ diff --git a/src/app/api/tuxedo/schedule-pdf/route.ts b/src/app/api/tuxedo/schedule-pdf/route.ts index 4e305d4..cec3a47 100644 --- a/src/app/api/tuxedo/schedule-pdf/route.ts +++ b/src/app/api/tuxedo/schedule-pdf/route.ts @@ -1,44 +1,44 @@ import { NextResponse } from "next/server"; import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; -import { pool } from "@/lib/db"; +import { supabase } from "@/lib/supabase"; + +type Settings = { + logo_url: string | null; + contact_email: string | null; + contact_phone: string | null; + schedule_pdf_notes: string | null; + brand_name: string | null; +}; export async function GET() { const brandSlug = "tuxedo"; - const { rows: brandRows } = await pool.query<{ id: string; name: string }>( - `SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`, - [brandSlug], - ); - const brand = brandRows[0]; + const { data: brand } = await supabase + .from("brands") + .select("id, name") + .eq("slug", brandSlug) + .single() as unknown as { data: { id: string; name: string } | null }; + if (!brand?.id) { return new NextResponse("Brand not found", { status: 404 }); } - const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>( - `SELECT city, state, date::text AS date, time, location - FROM stops - WHERE brand_id = $1 AND is_public = true AND status = 'active' - ORDER BY date ASC, time ASC`, - [brand.id], - ); + const { data: stops } = await supabase + .from("stops") + .select("city, state, date, time, location") + .eq("brand_id", brand.id) + .eq("active", true) + .order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null }; - const { rows: settingsRows } = await pool.query<{ - logo_url: string | null; - email: string | null; - phone: string | null; - schedule_pdf_notes: string | null; - }>( - `SELECT logo_url, email, phone, schedule_pdf_notes - FROM brand_settings - WHERE brand_id = $1 - LIMIT 1`, - [brand.id], - ); - const settings = settingsRows[0] ?? null; + const { data: settings } = await supabase + .from("brand_settings") + .select("logo_url, email, phone, schedule_pdf_notes") + .eq("brand_id", brand.id) + .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, - stops: stops, + stops: stops ?? [], logoUrl: settings?.logo_url ?? null, contactEmail: settings?.email ?? null, contactPhone: settings?.phone ?? null, @@ -73,8 +73,10 @@ async function buildProfessionalSchedulePdf({ const { width, height } = page.getSize(); const margin = 50; - const helvetica = await page.doc.embedFont(StandardFonts.Helvetica); - const helveticaBold = await page.doc.embedFont(StandardFonts.HelveticaBold); + const [helvetica, helveticaBold] = await Promise.all([ + page.doc.embedFont(StandardFonts.Helvetica), + page.doc.embedFont(StandardFonts.HelveticaBold), + ]); const colWidths = [130, 80, 80, 210]; const headers = ["City / State", "Date", "Time", "Location"]; diff --git a/src/app/api/v1/campaigns/route.ts b/src/app/api/v1/campaigns/route.ts index 3edb5fc..b82d40d 100644 --- a/src/app/api/v1/campaigns/route.ts +++ b/src/app/api/v1/campaigns/route.ts @@ -25,14 +25,14 @@ function validationError(error: z.ZodError) { // ============================================ const createCampaignSchema = z.object({ - brand_id: z.string().uuid(), + brand_id: z.uuid(), name: z.string().min(1).max(255), subject: z.string().min(1).max(500), content: z.string().min(1), type: z.enum(["email", "sms"]).default("email"), - segment_id: z.string().uuid().optional(), - contact_ids: z.array(z.string().uuid()).optional(), - scheduled_at: z.string().datetime().optional(), + segment_id: z.uuid().optional(), + contact_ids: z.array(z.uuid()).optional(), + scheduled_at: z.iso.datetime().optional(), }); export async function POST(req: NextRequest) { diff --git a/src/app/api/v1/products/route.ts b/src/app/api/v1/products/route.ts index 7150d84..6e3f3a1 100644 --- a/src/app/api/v1/products/route.ts +++ b/src/app/api/v1/products/route.ts @@ -27,7 +27,7 @@ function validationError(error: z.ZodError) { // ============================================ const getProductsSchema = z.object({ - brand_id: z.string().uuid().optional(), + brand_id: z.uuid().optional(), category: z.string().optional(), is_active: z.boolean().optional(), search: z.string().optional(), diff --git a/src/app/api/v1/referrals/route.ts b/src/app/api/v1/referrals/route.ts index bc439f0..201f4b4 100644 --- a/src/app/api/v1/referrals/route.ts +++ b/src/app/api/v1/referrals/route.ts @@ -25,8 +25,8 @@ function validationError(error: z.ZodError) { // ============================================ const createReferralSchema = z.object({ - brand_id: z.string().uuid(), - referred_email: z.string().email(), + brand_id: z.uuid(), + referred_email: z.email(), referral_code: z.string().min(6).max(50), }); diff --git a/src/app/api/v1/reports/route.ts b/src/app/api/v1/reports/route.ts index 0fc27a8..71e6b4d 100644 --- a/src/app/api/v1/reports/route.ts +++ b/src/app/api/v1/reports/route.ts @@ -24,9 +24,9 @@ function validationError(error: z.ZodError) { // ============================================ const reportFiltersSchema = z.object({ - brand_id: z.string().uuid(), - start_date: z.string().datetime(), - end_date: z.string().datetime(), + brand_id: z.uuid(), + start_date: z.iso.datetime(), + end_date: z.iso.datetime(), report_type: z.enum(["orders", "revenue", "customers", "products"]).default("orders"), }); diff --git a/src/app/api/v1/water-logs/route.ts b/src/app/api/v1/water-logs/route.ts index 239975e..80e360d 100644 --- a/src/app/api/v1/water-logs/route.ts +++ b/src/app/api/v1/water-logs/route.ts @@ -24,14 +24,14 @@ function validationError(error: z.ZodError) { // ============================================ const createWaterLogSchema = z.object({ - brand_id: z.string().uuid(), - field_id: z.string().uuid().optional(), + brand_id: z.uuid(), + field_id: z.uuid().optional(), field_name: z.string().max(255).optional(), gallons: z.number().positive(), duration_minutes: z.number().int().nonnegative().optional(), water_method: z.enum(["drip", "sprinkler", "flood", "manual"]).optional(), notes: z.string().max(500).optional(), - logged_at: z.string().datetime().optional(), + logged_at: z.iso.datetime().optional(), }); export async function POST(req: NextRequest) { diff --git a/src/app/api/water-qr-sheet/route.ts b/src/app/api/water-qr-sheet/route.ts index 74a1a77..b1e1d80 100644 --- a/src/app/api/water-qr-sheet/route.ts +++ b/src/app/api/water-qr-sheet/route.ts @@ -23,17 +23,20 @@ export async function POST(request: Request) { const siteUrl = baseUrl ?? process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"; - const qrDataUrls: string[] = []; - for (const hg of headgates) { - const url = `${siteUrl}/water?h=${hg.token}`; - const dataUrl = await QRCode.toDataURL(url, { - width: 280, - margin: 2, - color: { dark: "#000000", light: "#ffffff" }, - errorCorrectionLevel: "H", - }); - qrDataUrls.push(dataUrl); - } + const qrDataUrls: string[] = await Promise.all(headgates.map(async (hg) => { + try { + const url = `${siteUrl}/water?h=${hg.token}`; + return await QRCode.toDataURL(url, { + width: 280, + margin: 2, + color: { dark: "#000000", light: "#ffffff" }, + errorCorrectionLevel: "H", + }); + } catch (e) { + console.error(e); + return ""; + } + })); const rows = headgates.map((hg, i) => `
diff --git a/src/app/api/wholesale/checkout/route.ts b/src/app/api/wholesale/checkout/route.ts index 623f384..4a308d1 100644 --- a/src/app/api/wholesale/checkout/route.ts +++ b/src/app/api/wholesale/checkout/route.ts @@ -4,8 +4,7 @@ * TODO(migration): wholesale_orders is part of the legacy schema and * is read/written via raw `pool.query` SQL. The `get_wholesale_settings` * and `get_payment_settings` SECURITY DEFINER RPCs still live in the - * database (originally from the now-archived supabase migrations; - * consolidated into `db/migrations/0001_init.sql`) and are also called + * database (see supabase/migrations/046 and 045) and are also called * via `pool.query`. When wholesale is reactivated, declare the tables * in `db/schema/wholesale.ts` and switch the reads to typed Drizzle. */ diff --git a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts index 10cf648..816447a 100644 --- a/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts +++ b/src/app/api/wholesale/invoice/[orderId]/pdf/route.ts @@ -118,8 +118,10 @@ export async function GET( async function buildInvoicePdf(order: OrderRow, settings: InvoiceSettings) { const pdfDoc = await PDFDocument.create(); - const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); - const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const [helvetica, helveticaBold] = await Promise.all([ + pdfDoc.embedFont(StandardFonts.Helvetica), + pdfDoc.embedFont(StandardFonts.HelveticaBold), + ]); const page = pdfDoc.addPage([612, 792]); const { width, height } = page.getSize(); diff --git a/src/app/api/wholesale/invoice/[orderId]/route.ts b/src/app/api/wholesale/invoice/[orderId]/route.ts index 99fc6b4..ed66033 100644 --- a/src/app/api/wholesale/invoice/[orderId]/route.ts +++ b/src/app/api/wholesale/invoice/[orderId]/route.ts @@ -111,8 +111,10 @@ async function buildInvoicePdf( } ): Promise { const pdfDoc = await PDFDocument.create(); - const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica); - const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + const [helvetica, helveticaBold] = await Promise.all([ + pdfDoc.embedFont(StandardFonts.Helvetica), + pdfDoc.embedFont(StandardFonts.HelveticaBold), + ]); const page = pdfDoc.addPage([612, 792]); const { width } = page.getSize(); diff --git a/src/app/api/wholesale/notifications/send/route.ts b/src/app/api/wholesale/notifications/send/route.ts index b4888dd..d3e37c0 100644 --- a/src/app/api/wholesale/notifications/send/route.ts +++ b/src/app/api/wholesale/notifications/send/route.ts @@ -74,70 +74,92 @@ export async function POST() { let sent = 0; let failed = 0; - for (const n of notifications) { - if (!n.email_to) { - await markWholesaleNotificationSent(n.id, "No email_to address"); - failed++; - continue; + const results = await Promise.all(notifications.map(async (n) => { + let localSent = 0; + let localFailed = 0; + try { + if (!n.email_to) { + await markWholesaleNotificationSent(n.id, "No email_to address"); + localFailed++; + return { sent: localSent, failed: localFailed }; + } + + const settings = brandSettingsMap[n.brand_id]; + const activeRecipients = (settings?.notification_recipients ?? []) + .filter(r => r.active); + + // ── Fallback admin email if no active recipients ─────────────────────────── + const fallbackAdminEmail = + settings?.notification_email + ?? settings?.from_email + ?? settings?.invoice_business_email + ?? null; + + const fromEmail = n.invoice_business_email ?? "wholesale@routecommerce.com"; + + // ── Send to customer ─────────────────────────────────────────────────────── + const customerOk = await sendOneEmail(resendApiKey, fromEmail, n.email_to, n.email_cc ?? undefined, n.subject, n.body_html, n.body_text); + if (customerOk) { + await markWholesaleNotificationSent(n.id); + localSent++; + } else { + await markWholesaleNotificationSent(n.id, "Resend error"); + localFailed++; + return { sent: localSent, failed: localFailed }; + } + + // ── Send to notification recipients ─────────────────────────────────────── + const recipients = activeRecipients.length > 0 + ? activeRecipients + : fallbackAdminEmail ? [{ email: fallbackAdminEmail, name: undefined, active: true }] : []; + + const recipientResults = await Promise.all(recipients.map(async (recipient) => { + // Skip the customer email if they happen to also be a recipient (dedup) + if (recipient.email === n.email_to) return { ok: false, skipped: true }; + + const toAddress = recipient.name + ? `"${recipient.name}" <${recipient.email}>` + : recipient.email; + + try { + const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text); + + // Log a separate notification entry for audit trail + await pool.query( + "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)", + [ + n.brand_id, + n.customer_id, + n.order_id ?? null, + n.type, + recipient.email, + null, + n.subject, + n.body_html, + n.body_text, + ] + ); + + return { ok, skipped: false }; + } catch (e) { + console.error(e); + return { ok: false, skipped: false }; + } + })); + + for (const r of recipientResults) { + if (r.skipped) continue; + if (r.ok) localSent++; else localFailed++; + } + } catch (e) { + console.error(e); } + return { sent: localSent, failed: localFailed }; + })); - const settings = brandSettingsMap[n.brand_id]; - const activeRecipients = (settings?.notification_recipients ?? []) - .filter(r => r.active); - - // ── Fallback admin email if no active recipients ─────────────────────────── - const fallbackAdminEmail = - settings?.notification_email - ?? settings?.from_email - ?? settings?.invoice_business_email - ?? null; - - const fromEmail = n.invoice_business_email ?? "wholesale@routecommerce.com"; - - // ── Send to customer ─────────────────────────────────────────────────────── - const customerOk = await sendOneEmail(resendApiKey, fromEmail, n.email_to, n.email_cc ?? undefined, n.subject, n.body_html, n.body_text); - if (customerOk) { - await markWholesaleNotificationSent(n.id); - sent++; - } else { - await markWholesaleNotificationSent(n.id, "Resend error"); - failed++; - continue; - } - - // ── Send to notification recipients ─────────────────────────────────────── - const recipients = activeRecipients.length > 0 - ? activeRecipients - : fallbackAdminEmail ? [{ email: fallbackAdminEmail, name: undefined, active: true }] : []; - - for (const recipient of recipients) { - // Skip the customer email if they happen to also be a recipient (dedup) - if (recipient.email === n.email_to) continue; - - const toAddress = recipient.name - ? `"${recipient.name}" <${recipient.email}>` - : recipient.email; - - const ok = await sendOneEmail(resendApiKey, fromEmail, toAddress, undefined, n.subject, n.body_html, n.body_text); - - // Log a separate notification entry for audit trail - await pool.query( - "SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)", - [ - n.brand_id, - n.customer_id, - n.order_id ?? null, - n.type, - recipient.email, - null, - n.subject, - n.body_html, - n.body_text, - ] - ); - - if (ok) sent++; else failed++; - } + for (const r of results) { + sent += r.sent; + failed += r.failed; } await triggerPickupReminder(); diff --git a/src/app/api/wholesale/price-sheet/route.ts b/src/app/api/wholesale/price-sheet/route.ts index fd5f913..916e83f 100644 --- a/src/app/api/wholesale/price-sheet/route.ts +++ b/src/app/api/wholesale/price-sheet/route.ts @@ -5,10 +5,10 @@ * `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the * legacy schema. Reads are converted to `pool.query`; the * `enqueue_wholesale_notification` RPC is still in the database - * (originally from the now-archived supabase migrations; consolidated - * into `db/migrations/0001_init.sql`) and is called via `pool.query`. - * When wholesale is reactivated, move the tables into - * `db/schema/wholesale.ts` and switch reads to typed Drizzle. + * (supabase/migrations/054) and is called via `pool.query` rather + * than the Supabase REST gateway. When wholesale is reactivated, + * move the tables into `db/schema/wholesale.ts` and switch reads to + * typed Drizzle. */ import { NextRequest, NextResponse } from "next/server"; diff --git a/src/app/api/wholesale/webhooks/dispatch/route.ts b/src/app/api/wholesale/webhooks/dispatch/route.ts index b1c2be0..d0a49d8 100644 --- a/src/app/api/wholesale/webhooks/dispatch/route.ts +++ b/src/app/api/wholesale/webhooks/dispatch/route.ts @@ -85,53 +85,61 @@ export async function POST() { const pending = pendingRes.rows; 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); - - // Build HMAC-SHA256 signature: HMAC(secret, payload_string) - const signature = crypto - .createHmac("sha256", webhook.secret) - .update(payloadString) - .digest("hex"); - + const dispatchedResults = await Promise.all(pending.map(async (webhook) => { + let localDispatched = 0; try { - const res = await fetch(webhook.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Webhook-Signature": `sha256=${signature}`, - "X-Webhook-Event": webhook.event_type, - }, - body: payloadString, - }); - - const responseText = await res.text().catch(() => ""); - - if (res.ok) { - await markSent(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); - dispatched++; - } else { - await markFailed(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); + // 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"); + return localDispatched; } - } catch (err) { - const msg = err instanceof Error ? err.message : "Network error"; - await markFailed(webhook.id, msg); + + const payload = webhook.payload ?? {}; + const payloadString = JSON.stringify(payload); + + // Build HMAC-SHA256 signature: HMAC(secret, payload_string) + const signature = crypto + .createHmac("sha256", webhook.secret) + .update(payloadString) + .digest("hex"); + + try { + const res = await fetch(webhook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Signature": `sha256=${signature}`, + "X-Webhook-Event": webhook.event_type, + }, + body: payloadString, + }); + + const responseText = await res.text().catch(() => ""); + + if (res.ok) { + await markSent(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); + localDispatched++; + } else { + await markFailed(webhook.id, `HTTP ${res.status}: ${responseText.slice(0, 200)}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "Network error"; + await markFailed(webhook.id, msg); + } + } catch (e) { + console.error(e); } - } + return localDispatched; + })); + + dispatched = dispatchedResults.reduce((acc, r) => acc + r, 0); return NextResponse.json({ message: `Dispatched ${dispatched}/${pending.length} webhook(s).`, dispatched }); } diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index dd577a9..5ac8400 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -48,6 +48,11 @@ export const viewport: Viewport = { maximumScale: 5, }; +// Module-level constant for the copyright year. Evaluated once per +// server render (module scope), so the footer string is a static value +// — avoids hydration mismatch from `new Date()` in JSX. +const CURRENT_YEAR = new Date().getFullYear(); + const BLOG_POSTS = [ { slug: "getting-started-with-route-commerce", @@ -212,8 +217,8 @@ export default function BlogPage() {
- {RESOURCES.map((resource, i) => ( -
+ {RESOURCES.map((resource) => ( +
{resource.icon}
@@ -221,7 +226,7 @@ export default function BlogPage() {

{resource.description}

{resource.downloads} downloads -
@@ -241,7 +246,7 @@ export default function BlogPage() { Get weekly tips, industry insights, and product updates delivered to your inbox.

-
- © {new Date().getFullYear()} Route Commerce. All rights reserved. + © {CURRENT_YEAR} Route Commerce. All rights reserved.
diff --git a/src/app/brands/page.tsx b/src/app/brands/page.tsx index a563973..b20c8c8 100644 --- a/src/app/brands/page.tsx +++ b/src/app/brands/page.tsx @@ -4,6 +4,11 @@ import { useEffect, useRef } from "react"; import { gsap } from "gsap"; import Link from "next/link"; +// Module-level constant for the copyright year. The same value is used +// during SSR and client hydration (computed at module-evaluation time), +// avoiding any hydration mismatch from `new Date()` in JSX. +const CURRENT_YEAR = new Date().getFullYear(); + export default function BrandsPage() { const containerRef = useRef(null); @@ -212,7 +217,7 @@ export default function BrandsPage() {