diff --git a/.github/scripts/env.ts b/.github/scripts/env.ts index 9323386095..02b339a629 100644 --- a/.github/scripts/env.ts +++ b/.github/scripts/env.ts @@ -52,7 +52,10 @@ const apiFileContent = envFileContent .split('\n') .map((line) => { if (line.startsWith('ENVIRONMENT=')) { - return 'ENVIRONMENT=dev'; + // apiEnvSchema enforces z.enum(['development', 'production']) and + // throws on parse failure — keep the canonical 'development' value + // rather than the short 'dev' the script previously hardcoded. + return 'ENVIRONMENT=development'; } return line; }) diff --git a/.github/scripts/run-android-maestro-ci.ts b/.github/scripts/run-android-maestro-ci.ts index 8ed9726799..ad6f935fa4 100644 --- a/.github/scripts/run-android-maestro-ci.ts +++ b/.github/scripts/run-android-maestro-ci.ts @@ -52,6 +52,7 @@ await waitForReadyDevice(); await run(['adb', 'install', '-r', '-d', 'apps/expo/build/PackRat.apk']); await waitForReadyDevice(); await run(['adb', 'shell', 'pm', 'path', appId]); +await run(['adb', 'shell', 'pm', 'clear', appId]); await run(['adb', 'shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']); await waitForReadyDevice(); await run(['adb', 'shell', 'input', 'keyevent', 'KEYCODE_BACK'], { allowFailure: true }); diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index b4981dbf09..55c00edf60 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -88,10 +88,16 @@ jobs: - name: Cache node_modules uses: actions/cache@v4 with: + # Hash apps/expo/package.json too: bun's --frozen-lockfile happily + # installs on top of a partial cache, so when only the workspace + # package.json changes (e.g. nativewindui pin), the lockfile hash + # alone misses the bust and partial restore-keys hands us a stale + # tree (most recent symptom: @babel/helper-compilation-targets' + # nested lru-cache@5 disappears, breaking expo export). path: node_modules - key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }} - restore-keys: | - node-modules-${{ runner.os }}- + key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/expo/package.json', 'package.json') }} + # Drop the partial-prefix fallback. A miss should be a clean + # install, not a slow drift toward an inconsistent tree. - name: Install dependencies env: @@ -115,14 +121,16 @@ jobs: # Node-side scripts (migrate/seed) can't use the worker-only # db.localtest.me neon-proxy routing, so they hit raw Postgres on :5433. # The worker (.dev.vars below) uses db.localtest.me → proxy :4444. - - name: Run migrations + seed e2e user (local DB) + - name: Run migrations + seed e2e data (local DB) env: NEON_DATABASE_URL: postgres://test_user:test_password@localhost:5433/packrat_test E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }} E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }} + OPENAI_API_KEY: sk-e2e-stub-placeholder run: | bun run --filter @packrat/api db:migrate bun run --filter @packrat/api db:seed:e2e-user + bun run --filter @packrat/api db:seed:e2e-catalog # ── Write the worker's .dev.vars: real URLs + dummy-but-valid stand-ins ── # env-validation.ts requires ~25 keys to boot; sign-in only exercises the diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index 0521028793..f5f48de852 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -221,7 +221,10 @@ export default function AIChat() { trigger, messageId, }) => { - const authToken = token ?? (await getStoredSessionToken()); + // Pull the live web token at request time. useChat captures the + // transport at mount, so relying only on the hook token can go stale. + const { data } = await authClient.getSession(); + const authToken = data?.session?.token ?? token ?? (await getStoredSessionToken()); return { api, credentials: credentials ?? 'include', @@ -245,7 +248,7 @@ export default function AIChat() { }), transportKey: 'remote', }; - }, [aiMode, isLocalReady, modelStatus, token, tools, userId]); + }, [aiMode, isLocalReady, modelStatus, tools, userId]); // transportKey forces useChat to remount when the transport type switches, // since useChat captures the transport reference on mount and won't update it. diff --git a/apps/expo/app/(app)/trail-conditions.tsx b/apps/expo/app/(app)/trail-conditions.tsx index 984a7cf2f3..ce6f3675e6 100644 --- a/apps/expo/app/(app)/trail-conditions.tsx +++ b/apps/expo/app/(app)/trail-conditions.tsx @@ -57,7 +57,7 @@ export default function TrailConditionsScreen() { const filteredReports = useMemo(() => { if (!reports) return []; if (selectedSurface === 'all') return reports; - return reports.filter((r) => r.surface === selectedSurface); + return reports.filter((r: TrailConditionReport) => r.surface === selectedSurface); }, [reports, selectedSurface]); if (!featureFlags.enableTrailConditions) return null; diff --git a/apps/expo/features/ai/hooks/useReportedContent.ts b/apps/expo/features/ai/hooks/useReportedContent.ts index 45ec9a78a2..7744d7fbac 100644 --- a/apps/expo/features/ai/hooks/useReportedContent.ts +++ b/apps/expo/features/ai/hooks/useReportedContent.ts @@ -19,6 +19,8 @@ type ReportedContentResponse = { }>; }; +export type ReportedContentItem = ReportedContentResponse['reportedItems'][number]; + type ReportedContentCount = { count: number; total: number; diff --git a/apps/expo/features/ai/screens/ReportedContentScreen.tsx b/apps/expo/features/ai/screens/ReportedContentScreen.tsx index 8c4fc83e94..64ff93c459 100644 --- a/apps/expo/features/ai/screens/ReportedContentScreen.tsx +++ b/apps/expo/features/ai/screens/ReportedContentScreen.tsx @@ -7,7 +7,7 @@ import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useState } from 'react'; import { ActivityIndicator, FlatList, TouchableOpacity, View } from 'react-native'; -import { useReportedContent } from '../hooks/useReportedContent'; +import { type ReportedContentItem, useReportedContent } from '../hooks/useReportedContent'; import { useUpdateReportStatus } from '../hooks/useUpdateReportStatus'; import { reportReasonTranslationKeys } from '../lib/reportReasons'; @@ -21,7 +21,7 @@ export default function ReportedContentScreen() { const { data, isLoading, error } = useReportedContent(); const updateMutation = useUpdateReportStatus(); - const filteredData = data?.filter((item) => { + const filteredData = data?.filter((item: ReportedContentItem) => { if (selectedFilter === 'all') return true; return item.status === selectedFilter; }); diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index 82c09368ff..c80391dc9c 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -100,7 +100,7 @@ export function CatalogItemDetailScreen() { {t('catalog.categoriesLabel')} - {item.categories.map((category) => ( + {item.categories.map((category: string) => ( {decodeHtmlEntities(category)} @@ -212,7 +212,7 @@ export function CatalogItemDetailScreen() { {Object.entries(item.techs).map(([key, value]) => ( {key} - {value} + {String(value)} ))} diff --git a/apps/expo/features/packs/screens/PackListScreen.tsx b/apps/expo/features/packs/screens/PackListScreen.tsx index 3e22be8b08..956d8407a0 100644 --- a/apps/expo/features/packs/screens/PackListScreen.tsx +++ b/apps/expo/features/packs/screens/PackListScreen.tsx @@ -40,6 +40,12 @@ type FilterOption = { value: PackCategory | 'all'; }; +type PackListRow = { + id: string; + name: string; + category: PackCategory | null; +}; + function CreatePackIconButton() { const { colors } = useColorScheme(); const { t } = useTranslation(); @@ -90,7 +96,7 @@ export function PackListScreen() { const packs = selectedTypeIndex === USER_PACKS_INDEX ? userPacks : allPacksQuery.data; - const filteredPacks = packs?.filter((pack) => { + const filteredPacks = packs?.filter((pack: PackListRow) => { const matchesSearch = pack.name.toLowerCase().includes(searchValue.toLowerCase()); const matchesCategory = activeFilter === 'all' || pack.category === activeFilter; return matchesSearch && matchesCategory; diff --git a/apps/expo/features/packs/utils/getPackDetailOptions.tsx b/apps/expo/features/packs/utils/getPackDetailOptions.tsx index ef544a589f..78161333f6 100644 --- a/apps/expo/features/packs/utils/getPackDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackDetailOptions.tsx @@ -22,14 +22,16 @@ export function getPackDetailOptions(id: string) { if (!isOwner) return null; - const confirmDelete = () => { - const deleteAndNavigate = () => { - deletePack(id); - if (router.canGoBack()) { - router.back(); - } - }; + const deleteAndNavigate = () => { + deletePack(id); + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/packs'); + } + }; + const confirmDelete = () => { if (Platform.OS === 'web') { if (globalThis.confirm(t('packs.deletePackConfirm'))) { deleteAndNavigate(); diff --git a/apps/expo/features/trips/hooks/useDeleteTrip.ts b/apps/expo/features/trips/hooks/useDeleteTrip.ts index 2bf30e8441..8b3f0d9922 100644 --- a/apps/expo/features/trips/hooks/useDeleteTrip.ts +++ b/apps/expo/features/trips/hooks/useDeleteTrip.ts @@ -6,20 +6,33 @@ import { useCallback } from 'react'; export function useDeleteTrip() { const deleteTrip = useCallback(async (id: string) => { - // Optimistically mark as deleted in the local store so the UI updates immediately + // Optimistic local flip so the UI hides the trip immediately. const tripObs = obs({ store: tripsStore, id }); - if (tripObs) { - tripObs.deleted.set(true); - } - // Hard-delete on the server so the list GET won't return the trip on any subsequent reload - const { error } = await apiClient.trips({ tripId: id }).delete(); - if (error) { - const err = new Error(String(error.value ?? 'Failed to delete trip')); - Sentry.captureException(err, { - tags: { feature: 'trips', action: 'deleteTrip' }, - extra: { tripId: id, apiError: error.value, httpStatus: error.status }, + if (tripObs) tripObs.deleted.set(true); + + Sentry.addBreadcrumb({ + category: 'trips', + message: 'Deleting trip', + level: 'info', + data: { tripId: id }, + }); + + // Two failure modes: a transport rejection (network drop, fetch abort) + // and a non-2xx response (server still has the trip). In both we need + // to undo the optimistic flip, capture, and rethrow so the caller can + // surface it. 404 is treated as success since the trip is already gone. + try { + const response = await apiClient.trips({ tripId: id }).delete(); + if (response.error && response.status !== 404) { + throw new Error(`Trip delete failed (${response.status})`); + } + } catch (error) { + if (tripObs) tripObs.deleted.set(false); + Sentry.captureException(error, { + tags: { feature: 'trips', action: 'delete' }, + extra: { tripId: id }, }); - throw err; + throw error; } }, []); diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx index 99bbe2ef63..98e1de69d0 100644 --- a/apps/expo/features/trips/screens/TripDetailScreen.tsx +++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx @@ -5,6 +5,7 @@ import { featureFlags } from 'expo-app/config'; import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { testIds } from 'expo-app/lib/testIds'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; import { Modal, ScrollView, View } from 'react-native'; @@ -74,7 +75,7 @@ export function TripDetailScreen() { {trip.name} {/* Dates */} - + {t('trips.dates')} diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index abf1c674cd..40c57e69fc 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -2,20 +2,53 @@ import { createApiClient } from '@packrat/api-client'; import { store } from 'expo-app/atoms/store'; import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { getApiBaseUrl } from 'expo-app/lib/api/getBaseUrl'; -import { authClient, getStoredSessionToken } from 'expo-app/lib/auth-client'; +import { authClient, parseSessionToken } from 'expo-app/lib/auth-client'; +import * as SecureStore from 'expo-app/lib/secureStore'; +import { Platform } from 'react-native'; + +const COOKIE_STORE_KEY = 'packrat_cookie'; + +// On web expoClient short-circuits and expo-secure-store is an empty stub, so +// we fall back to authClient.getSession(). Cache the token for 30s to keep +// apiClient's per-request token lookup from tripping Better Auth's prod +// rate limit. Invalidated by onNeedsReauth. +const WEB_TOKEN_CACHE_MS = 30_000; +let cachedToken: string | null = null; +let cachedTokenExpiresAt = 0; +let pendingTokenRequest: Promise | null = null; export const apiClient = createApiClient({ baseUrl: getApiBaseUrl(), auth: { - // Read the token from SecureStore — no network call on every API request. - getAccessToken: getStoredSessionToken, + getAccessToken: async () => { + if (Platform.OS === 'web') { + const now = Date.now(); + if (cachedToken && now < cachedTokenExpiresAt) return cachedToken; + pendingTokenRequest ??= authClient + .getSession() + .then(({ data }) => { + cachedToken = data?.session?.token ?? null; + cachedTokenExpiresAt = Date.now() + WEB_TOKEN_CACHE_MS; + return cachedToken; + }) + .finally(() => { + pendingTokenRequest = null; + }); + return pendingTokenRequest; + } + const cookieStr = await SecureStore.getItemAsync(COOKIE_STORE_KEY); + return parseSessionToken(cookieStr); + }, // Better Auth has no separate refresh-token endpoint; the 7-day session // token is the only credential. Returning null here is intentional. getRefreshToken: () => null, onAccessTokenRefreshed: () => {}, onNeedsReauth: async () => { - // A 401 can be transient (e.g. the server briefly returned an error). - // Verify the session is actually gone before alarming the user. + cachedToken = null; + cachedTokenExpiresAt = 0; + pendingTokenRequest = null; + // 401 can be transient; verify the session is really gone before + // bouncing the user. const { data } = await authClient.getSession(); if (data?.session) return; store.set(needsReauthAtom, true); diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index 8f960aee78..8b11bbb841 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -462,6 +462,7 @@ "viewDetails": "View Details", "deleteTrip": "Delete trip?", "deleteTripConfirmation": "Are you sure you want to delete this trip? This action cannot be undone.", + "deleteTripFailed": "Could not delete this trip. Please try again.", "noTripsYetTitle": "No Trips Yet", "createTripsToSee": "Create trips to start seeing your upcoming adventures!", "gotIt": "Got it", diff --git a/apps/expo/mocks/react-native-community-datetimepicker.tsx b/apps/expo/mocks/react-native-community-datetimepicker.tsx index a437cced6a..4b541ac0eb 100644 --- a/apps/expo/mocks/react-native-community-datetimepicker.tsx +++ b/apps/expo/mocks/react-native-community-datetimepicker.tsx @@ -10,6 +10,10 @@ type Props = { minimumDate?: Date; maximumDate?: Date; style?: unknown; + // RN's `testID` doesn't auto-flow into a plain the way it does for + // react-native-web's /. Forward it explicitly so Playwright's + // getByTestId can target the rendered date input. + testID?: string; }; function toInputValue(date: Date, mode: Props['mode']): string { @@ -25,6 +29,7 @@ export default function DateTimePicker({ onChange, minimumDate, maximumDate, + testID, }: Props) { const inputType = mode === 'time' ? 'time' : mode === 'datetime' ? 'datetime-local' : 'date'; @@ -39,6 +44,7 @@ export default function DateTimePicker({ return ( { page.on('dialog', (dialog) => dialog.accept()); - const deleteSyncPromise = page.waitForResponse( - (r) => - r.url().includes(`/api/packs/${packId}`) && - (r.request().method() === 'PUT' || r.request().method() === 'PATCH'), - { timeout: 20_000 }, - ); - const deleteButton = page.getByTestId(testIds.packs.deleteBtn); await deleteButton.waitFor({ timeout: 15_000 }); await deleteButton.click(); - const response = await deleteSyncPromise; - expect(response.ok()).toBeTruthy(); - - // After deletion has synced, go to list and confirm pack is gone. + // Pack deletion is a local soft-delete in the synced store; confirm the UI result. await page.goto(`${BASE_URL}/packs`); await expect(page.getByText(packName)).not.toBeVisible({ timeout: 10_000 }); }); @@ -213,8 +203,8 @@ test.describe('Item CRUD within a pack', () => { if (await moreActionsButton.isVisible()) { await moreActionsButton.click(); const deleteOption = page - .getByText(/delete/i) - .or(page.getByRole('menuitem', { name: /delete/i })) + .getByRole('menuitem', { name: /^Delete$/ }) + .or(page.getByRole('button', { name: /^Delete$/ })) .first(); await deleteOption.waitFor({ timeout: 5_000 }); await deleteOption.click(); diff --git a/apps/expo/providers/index.web.tsx b/apps/expo/providers/index.web.tsx index c50e6145c5..406317a70e 100644 --- a/apps/expo/providers/index.web.tsx +++ b/apps/expo/providers/index.web.tsx @@ -1,3 +1,4 @@ +import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { PortalHost } from '@rn-primitives/portal'; import { ErrorBoundary } from 'expo-app/components/initial/ErrorBoundary'; @@ -9,11 +10,10 @@ import { JotaiProvider } from './JotaiProvider'; import { TanstackProvider } from './TanstackProvider'; /** - * Web version of Providers. - * Removes native-only providers: - * - KeyboardProvider (react-native-keyboard-controller — no web support) - * - ActionSheetProvider (@expo/react-native-action-sheet uses React.Children.only which breaks on web) - * Metro automatically picks this file over providers/index.tsx for web builds. + * Web Providers. Drops KeyboardProvider (no web support); keeps + * BottomSheetModalProvider for inline BottomSheetView and ActionSheetProvider + * for useActionSheet(). CustomActionSheet wraps its child in + * React.Children.only — keep the direct child a single element. */ export function Providers({ children }: { children: ReactNode }) { return ( @@ -22,10 +22,14 @@ export function Providers({ children }: { children: ReactNode }) { - - {children} - - + + + <> + {children} + + + + diff --git a/packages/api/package.json b/packages/api/package.json index 3cb512b880..4f370879f4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,6 +28,7 @@ "db:generate": "drizzle-kit generate --config=drizzle.config.ts", "db:migrate": "bun run ./migrate.ts", "db:seed": "bun run ./src/db/seed.ts", + "db:seed:e2e-catalog": "bun run ./src/db/seed-e2e-catalog.ts", "db:seed:e2e-user": "bun run ./src/db/seed-e2e-user.ts", "deploy": "wrangler deploy --minify", "deploy:dev": "wrangler deploy --minify -e=dev", diff --git a/packages/api/src/auth/auth.config.ts b/packages/api/src/auth/auth.config.ts index dba1f58e03..d5c9793e26 100644 --- a/packages/api/src/auth/auth.config.ts +++ b/packages/api/src/auth/auth.config.ts @@ -13,7 +13,7 @@ import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { neon } from '@neondatabase/serverless'; import * as schema from '@packrat/db'; -import { betterAuth } from 'better-auth'; +import { type BetterAuthPlugin, betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; import { drizzle } from 'drizzle-orm/neon-http'; @@ -24,7 +24,9 @@ export const auth = betterAuth({ secret: 'cli-stub-secret', advanced: { - generateId: () => crypto.randomUUID(), + database: { + generateId: () => crypto.randomUUID(), + }, ipAddress: { ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'], }, @@ -69,7 +71,12 @@ export const auth = betterAuth({ }, }, - plugins: [bearer(), jwt({ jwks: { disablePrivateKeyEncryption: true } }), admin()], + plugins: [ + bearer(), + jwt({ jwks: { disablePrivateKeyEncryption: true } }), + // safe-cast: Better Auth 1.6.13's admin plugin return type is narrower than BetterAuthPlugin. + admin() as unknown as BetterAuthPlugin, + ], trustedOrigins: ['http://localhost:8787', 'packrat://'], }); diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 4958db7aab..83bcda5e60 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -14,7 +14,7 @@ import { createConnection } from '@packrat/api/db'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; import * as schema from '@packrat/db'; import { isObject } from '@packrat/guards'; -import { betterAuth } from 'better-auth'; +import { type BetterAuthPlugin, betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; // ─── Per-isolate auth instance cache ───────────────────────────────────────── @@ -61,7 +61,9 @@ async function buildAuth(env: ValidatedEnv): Promise { advanced: { // All IDs are UUID-formatted text (matching the DB migration). - generateId: () => crypto.randomUUID(), + database: { + generateId: () => crypto.randomUUID(), + }, // Trust the X-Forwarded-For header added by Cloudflare. ipAddress: { ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'], @@ -190,7 +192,8 @@ async function buildAuth(env: ValidatedEnv): Promise { }), // Admin: role-based user management endpoints. - admin(), + // safe-cast: Better Auth 1.6.13's admin plugin return type is narrower than BetterAuthPlugin. + admin() as unknown as BetterAuthPlugin, // Expo: promotes the expo-origin header → Origin so the CSRF check // passes for requests from the native app (which can't send a browser diff --git a/packages/api/src/db/seed-e2e-catalog.ts b/packages/api/src/db/seed-e2e-catalog.ts new file mode 100644 index 0000000000..f009cde313 --- /dev/null +++ b/packages/api/src/db/seed-e2e-catalog.ts @@ -0,0 +1,267 @@ +/** + * Seed a handful of catalog items so the catalog-tab and catalog-search + * Playwright tests have data to render and filter against. Idempotent on + * SKU — re-running won't duplicate rows. + * + * Usage: + * NEON_DATABASE_URL= OPENAI_API_KEY= \ + * bun run packages/api/src/db/seed-e2e-catalog.ts + * + * Why this script exists: in production the `catalog_items` table is + * populated by the ETL workflow scraping product pages. Local dev DBs + * (docker-compose.test.yml) start empty, so anything that scrolls the + * catalog tab or runs a similarity search has nothing to find. + */ + +import { neon, neonConfig } from '@neondatabase/serverless'; +import * as schema from '@packrat/db/schema'; +import { nodeEnv } from '@packrat/env/node'; +import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http'; +import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import WebSocket from 'ws'; + +neonConfig.webSocketConstructor = WebSocket; + +// Mirrors packages/api/src/db/index.ts so the seed script picks the same +// driver path as the runtime — pg for raw Postgres (postgres:// or +// postgresql://), neon-http for Neon and for the local db.localtest.me proxy +// (which speaks Neon's HTTP wire format despite the URL looking standard). +const isStandardPostgresUrl = (url: string) => { + try { + const u = new URL(url); + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + const isLocalNeonProxy = host === 'db.localtest.me'; + return ( + (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && + !isNeonTech && + !isNeonCom && + !isLocalNeonProxy + ); + } catch { + return false; + } +}; + +type SeedItem = { + sku: string; + name: string; + description: string; + weight: number; + weightUnit: 'g' | 'oz'; + categories: string[]; + brand?: string; +}; + +// A small, hand-picked set covering distinct gear categories so the catalog +// search test ("sleeping bag") and the add-from-catalog test (just needs at +// least one item) both have meaningful hits. +const ITEMS: SeedItem[] = [ + { + sku: 'e2e-sleeping-bag-20f', + name: 'Mountain Loft Down Sleeping Bag 20°F', + description: 'Lightweight down sleeping bag rated to 20°F for 3-season backpacking.', + weight: 980, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'TrailLab', + }, + { + sku: 'e2e-sleeping-bag-30f', + name: 'Sierra Lite Sleeping Bag 30°F', + description: 'Compact synthetic sleeping bag for summer hiking.', + weight: 720, + weightUnit: 'g', + categories: ['Sleep System'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-tent-2p', + name: 'Cascade 2P Backpacking Tent', + description: 'Two-person freestanding tent, 1.6 kg packed weight, double-wall.', + weight: 1600, + weightUnit: 'g', + categories: ['Shelter'], + brand: 'TrailLab', + }, + { + sku: 'e2e-backpack-55l', + name: '55L Hiking Backpack', + description: 'Internal frame pack with hydration sleeve and rain cover.', + weight: 1500, + weightUnit: 'g', + categories: ['Packs'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-stove-canister', + name: 'Spark Mini Canister Stove', + description: 'Pocket-sized canister stove, boils 500ml in 3:30.', + weight: 78, + weightUnit: 'g', + categories: ['Cooking'], + }, + { + sku: 'e2e-headlamp', + name: 'Beam 350 Headlamp', + description: 'Rechargeable 350-lumen headlamp with red-light mode.', + weight: 68, + weightUnit: 'g', + categories: ['Lighting'], + }, + { + sku: 'e2e-water-filter', + name: 'Squeeze Water Filter', + description: 'Hollow-fiber filter that removes 99.99% of bacteria and protozoa.', + weight: 90, + weightUnit: 'g', + categories: ['Water'], + }, + { + sku: 'e2e-rain-jacket', + name: 'Stormshield Rain Jacket', + description: 'Three-layer waterproof breathable shell with pit zips.', + weight: 320, + weightUnit: 'g', + categories: ['Apparel'], + }, + { + sku: 'e2e-puffy', + name: 'Featherlite 800-Fill Down Jacket', + description: 'Insulated puffy jacket for cold belays and camp wear.', + weight: 290, + weightUnit: 'g', + categories: ['Apparel'], + brand: 'PackRat Gear', + }, + { + sku: 'e2e-sleeping-pad', + name: 'Aircell Insulated Sleeping Pad', + description: 'R-value 4.2 inflatable sleeping pad, 460 g packed.', + weight: 460, + weightUnit: 'g', + categories: ['Sleep System'], + }, +]; + +const EMBEDDING_DIMENSIONS = 1536; +const EMBEDDING_REQUEST_TIMEOUT_MS = 10_000; + +async function embedAll(opts: { values: string[]; openAiKey: string }): Promise { + const { values, openAiKey } = opts; + if (openAiKey.startsWith('sk-e2e-stub-')) { + return values.map((value) => { + let hash = 2166136261; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + + return Array.from({ length: EMBEDDING_DIMENSIONS }, (_, i) => { + hash ^= i; + hash = Math.imul(hash, 16777619); + return (hash >>> 0) / 0xffffffff; + }); + }); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), EMBEDDING_REQUEST_TIMEOUT_MS); + let res: Response; + try { + res = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${openAiKey}`, + }, + body: JSON.stringify({ model: 'text-embedding-3-small', input: values }), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`OpenAI embeddings timed out after ${EMBEDDING_REQUEST_TIMEOUT_MS}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenAI embeddings failed ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as { data: Array<{ embedding: number[] }> }; + return json.data.map((d) => d.embedding); +} + +async function seedCatalog() { + const dbUrl = nodeEnv.NEON_DATABASE_URL; + const openAiKey = nodeEnv.OPENAI_API_KEY; + if (!dbUrl) throw new Error('NEON_DATABASE_URL is required'); + if (!openAiKey) throw new Error('OPENAI_API_KEY is required'); + + type SeedDatabase = NodePgDatabase | NeonHttpDatabase; + let db: SeedDatabase; + let pgClient: Client | undefined; + + if (isStandardPostgresUrl(dbUrl)) { + pgClient = new Client({ connectionString: dbUrl }); + await pgClient.connect(); + db = drizzlePg(pgClient, { schema }); + } else { + db = drizzle(neon(dbUrl), { schema }); + } + + try { + const existing = await db.select({ sku: schema.catalogItems.sku }).from(schema.catalogItems); + const knownSkus = new Set(existing.map((r) => r.sku)); + const newItems = ITEMS.filter((i) => !knownSkus.has(i.sku)); + if (newItems.length === 0) { + console.log(`Catalog already seeded (${existing.length} rows).`); + return; + } + + console.log(`Generating ${newItems.length} embeddings via OpenAI...`); + const embeddings = await embedAll({ + values: newItems.map((i) => `${i.name}. ${i.description}`), + openAiKey, + }); + if (embeddings.length !== newItems.length) { + throw new Error( + `Embedding count mismatch: expected ${newItems.length}, got ${embeddings.length}`, + ); + } + + for (let i = 0; i < newItems.length; i++) { + const item = newItems[i]; + const embedding = embeddings[i]; + if (!item || !embedding || embedding.length !== EMBEDDING_DIMENSIONS) { + throw new Error( + `Invalid embedding at index ${i}: expected ${EMBEDDING_DIMENSIONS} dimensions`, + ); + } + await db.insert(schema.catalogItems).values({ + name: item.name, + productUrl: `https://example.com/${item.sku}`, + sku: item.sku, + weight: item.weight, + weightUnit: item.weightUnit, + description: item.description, + categories: item.categories, + brand: item.brand ?? null, + embedding, + }); + } + console.log(`Seeded ${newItems.length} catalog items.`); + } finally { + await pgClient?.end(); + } +} + +seedCatalog().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 738e616269..3399f715df 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -151,12 +151,16 @@ function enrichEnv(env: Env): Env { // and prod share the exact same driver path — no node-postgres TCP sockets // (which workerd silently drops between requests). let neonLocalConfigured = false; -function maybeConfigureLocalNeon(databaseUrl: string | undefined): void { +function maybeConfigureLocalNeon(opts: { + databaseUrl: string | undefined; + proxyPortOverride: string | undefined; +}): void { + const { databaseUrl, proxyPortOverride } = opts; if (neonLocalConfigured || !databaseUrl) return; try { const host = new URL(databaseUrl).hostname.toLowerCase(); if (host !== 'db.localtest.me') return; - const proxyPort = '4444'; + const proxyPort = proxyPortOverride ?? '4444'; neonConfig.fetchEndpoint = (h) => h === 'db.localtest.me' ? `http://${h}:${proxyPort}/sql` : `https://${h}/sql`; neonConfig.wsProxy = (h) => (h === 'db.localtest.me' ? `${h}:${proxyPort}/v2` : `${h}/v2`); @@ -171,7 +175,10 @@ function maybeConfigureLocalNeon(databaseUrl: string | undefined): void { const handler: ExportedHandler = { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const e = enrichEnv(env); - maybeConfigureLocalNeon(e.NEON_DATABASE_URL); + maybeConfigureLocalNeon({ + databaseUrl: e.NEON_DATABASE_URL, + proxyPortOverride: e.NEON_LOCAL_PROXY_PORT, + }); setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design return (app.fetch as unknown as CfFetchFn)(request, e, ctx); // safe-cast: Elysia's fetch has Cloudflare-specific env/ctx params not in the standard type diff --git a/packages/api/src/services/__tests__/embeddingService.test.ts b/packages/api/src/services/__tests__/embeddingService.test.ts index 576a83d86a..d412a73bb5 100644 --- a/packages/api/src/services/__tests__/embeddingService.test.ts +++ b/packages/api/src/services/__tests__/embeddingService.test.ts @@ -120,6 +120,22 @@ describe('embeddingService', () => { cloudflareAiBinding: expect.anything(), }); }); + + it('returns deterministic embeddings for e2e stub keys', async () => { + const first = await generateEmbedding({ + ...baseParams, + openAiApiKey: 'sk-e2e-stub-placeholder', + value: 'test\ntext', + }); + const second = await generateEmbedding({ + ...baseParams, + openAiApiKey: 'sk-e2e-stub-placeholder', + value: 'test text', + }); + + expect(first).toHaveLength(1536); + expect(first).toEqual(second); + }); }); describe('generateManyEmbeddings', () => { @@ -220,5 +236,18 @@ describe('embeddingService', () => { values: ['text with spaces'], }); }); + + it('returns deterministic embeddings for e2e stub keys', async () => { + const result = await generateManyEmbeddings({ + ...baseParams, + openAiApiKey: 'sk-e2e-stub-placeholder', + values: ['first\nitem', '', 'second item'], + }); + + expect(result).toHaveLength(2); + expect(result[0]).toHaveLength(1536); + expect(result[1]).toHaveLength(1536); + expect(result[0]).not.toEqual(result[1]); + }); }); }); diff --git a/packages/api/src/services/embeddingService.ts b/packages/api/src/services/embeddingService.ts index 8119178663..d0b11b8ac5 100644 --- a/packages/api/src/services/embeddingService.ts +++ b/packages/api/src/services/embeddingService.ts @@ -5,6 +5,7 @@ import { embed, embedMany } from 'ai'; // ── Embedding text normalization ────────────────────────────────────── const NEWLINE = /\n/g; +const E2E_EMBEDDING_DIMENSIONS = 1536; type GenerateEmbeddingBaseParams = { openAiApiKey?: string; @@ -19,6 +20,22 @@ type GenerateEmbeddingParams = GenerateEmbeddingBaseParams & { value: string; }; +const isE2EStubKey = (key?: string) => key?.startsWith('sk-e2e-stub-') === true; + +const deterministicEmbedding = (value: string): number[] => { + let hash = 2166136261; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + + return Array.from({ length: E2E_EMBEDDING_DIMENSIONS }, (_, i) => { + hash ^= i; + hash = Math.imul(hash, 16777619); + return (hash >>> 0) / 0xffffffff; + }); +}; + export const generateEmbedding = async ( params: GenerateEmbeddingParams, ): Promise => { @@ -29,11 +46,15 @@ export const generateEmbedding = async ( return null; } - const aiProvider = createAIProvider(providerConfig); - // OpenAI recommends replacing newlines with spaces for best results const input = value.replace(NEWLINE, ' '); + if (isE2EStubKey(providerConfig.openAiApiKey)) { + return deterministicEmbedding(input); + } + + const aiProvider = createAIProvider(providerConfig); + const { embedding } = await embed({ model: aiProvider.embedding(DEFAULT_MODELS.OPENAI_EMBEDDING), value: input, @@ -57,6 +78,10 @@ export const generateManyEmbeddings = async ( return []; } + if (isE2EStubKey(providerConfig.openAiApiKey)) { + return cleanValues.map(deterministicEmbedding); + } + const aiProvider = createAIProvider(providerConfig); const { embeddings } = await embedMany({ diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 9ebb664c14..06f8ac9c34 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -16,6 +16,10 @@ export const apiEnvObjectSchema = z.object({ // Optional: trail routes return 503 when absent. For Cloudflare Workers, // set to env.OSM_HYPERDRIVE.connectionString (Hyperdrive binding). OSM_DATABASE_URL: z.string().url().optional(), + // Local-only override for the host port of the local-neon-http-proxy + // (docker-compose.test.yml). Worker entry routes the neon driver to this + // port when NEON_DATABASE_URL points at db.localtest.me. Defaults to 4444. + NEON_LOCAL_PROXY_PORT: z.string().regex(/^\d+$/).optional(), // Better Auth BETTER_AUTH_SECRET: z.string().min(32), diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts index e7b92e8f40..a95221438d 100644 --- a/packages/env/src/node.ts +++ b/packages/env/src/node.ts @@ -79,6 +79,9 @@ export const nodeEnvSchema = z.object({ // ── E2E test credentials ────────────────────────────────────────── E2E_TEST_EMAIL: z.string().email().optional(), E2E_TEST_PASSWORD: z.string().min(1).optional(), + + // ── OpenAI (packages/api/src/db/seed-e2e-catalog.ts) ────────────── + OPENAI_API_KEY: z.string().min(1).optional(), E2E_API_URL: z.string().url().optional(), E2E_DB_URL: z.string().url().optional(), E2E_DB_PORT: z.string().regex(/^\d+$/, 'E2E_DB_PORT must be a numeric string').optional(), @@ -137,6 +140,7 @@ export const nodeEnv = nodeEnvSchema.parse({ DEBUG: process.env.DEBUG, E2E_TEST_EMAIL: process.env.E2E_TEST_EMAIL, E2E_TEST_PASSWORD: process.env.E2E_TEST_PASSWORD, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, E2E_API_URL: process.env.E2E_API_URL, E2E_DB_URL: process.env.E2E_DB_URL, E2E_DB_PORT: process.env.E2E_DB_PORT, diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch deleted file mode 100644 index ffcfa0e9a6..0000000000 --- a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch +++ /dev/null @@ -1,106 +0,0 @@ -diff --git a/node_modules/@packrat-ai/nativewindui/.bun-tag-85507b58e8a01901 b/.bun-tag-85507b58e8a01901 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/package.json b/package.json -index d8cbd357556aa4e99da8aeb1e80290cb8edad082..a88f91e07aeab9facef841075b4aeb9f49beb879 100644 ---- a/package.json -+++ b/package.json -@@ -1,6 +1,6 @@ - { - "name": "@packrat-ai/nativewindui", -- "version": "2.0.3", -+ "version": "2.0.3-2", - "entry": "src/index.ts", - "main": "src/index.ts", - "types": "src/index.ts", -@@ -43,7 +43,7 @@ - "expo-image": "~3.0.11", - "expo-linear-gradient": "~15.0.8", - "expo-navigation-bar": "~5.0.10", -- "expo-router": "~6.0.23", -+ "expo-router": ">=6.0.23", - "expo-symbols": "~1.0.8", - "nativewind": "^4.2.3", - "react": ">=19.0.0", -diff --git a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx -index 86091777372a04b76c9e866d2951bcfde9f34e05..deac88175ef8d3b14a4d7ff6a476ab6b7d3a3e71 100644 ---- a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx -+++ b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx -@@ -186,7 +186,12 @@ export function AdaptiveSearchHeader(props: AdaptiveSearchHeaderProps) { - onFocus={props.searchBar?.onFocus} - value={searchValue} - onChangeText={onChangeText} -- autoCapitalize={props.searchBar?.autoCapitalize} -+ autoCapitalize={ -+ props.searchBar?.autoCapitalize === undefined || -+ props.searchBar?.autoCapitalize === 'systemDefault' -+ ? 'none' -+ : props.searchBar?.autoCapitalize -+ } - keyboardType={searchBarInputTypeToKeyboardType(props.searchBar?.inputType)} - returnKeyType="search" - blurOnSubmit={props.searchBar?.materialBlurOnSubmit} -diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts -index 786b62c4a216c360beb193b96092186319a634cb..56b991f34884160c9b5c484415c94c5172b0f25c 100644 ---- a/src/components/Icon/types.ts -+++ b/src/components/Icon/types.ts -@@ -1,16 +1,18 @@ - import type MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons'; - import type MaterialIcons from '@expo/vector-icons/MaterialIcons'; --import type { SymbolViewProps } from 'expo-symbols'; -+import type { SymbolViewProps as ExpoSymbolViewProps } from 'expo-symbols'; - import type { IconMapper } from 'rn-icon-mapper'; - - type MaterialCommunityIconsProps = React.ComponentProps; - type MaterialIconsProps = React.ComponentProps; - --type Style = SymbolViewProps['style'] & -+type SymbolViewPropsWithStringName = Omit & { name: string }; -+ -+type Style = SymbolViewPropsWithStringName['style'] & - MaterialIconsProps['style'] & - MaterialCommunityIconsProps['style']; - --type IconProps = IconMapper & { -+type IconProps = IconMapper & { - style?: Style; - className?: string; - }; -diff --git a/src/components/LargeTitleHeader/LargeTitleHeader.tsx b/src/components/LargeTitleHeader/LargeTitleHeader.tsx -index 95c66bbdece307542084c2e510c847543134f63e..d6cf24dbaf209ea0086cfb05d9203c81585578a2 100644 ---- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx -+++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx -@@ -157,6 +157,7 @@ export function LargeTitleHeader(props: LargeTitleHeaderProps) { - - {!!props.searchBar && ( -