diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 8f7ab4a00b..74513ed103 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -16,6 +16,23 @@ permissions: jobs: api-tests: runs-on: ubuntu-latest + + services: + postgres: + image: pgvector/pgvector:pg15 + env: + POSTGRES_DB: packrat_test + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5433:5432 + options: >- + --health-cmd "pg_isready -U test_user -d packrat_test" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 @@ -28,5 +45,33 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install + - name: Run database migrations + env: + NEON_DATABASE_URL: postgres://test_user:test_password@localhost:5433/packrat_test + run: | + cd packages/api + bun run migrate + - name: Run API tests + env: + # Test database configuration + NEON_DATABASE_URL: postgres://test_user:test_password@localhost:5433/packrat_test + NEON_DATABASE_URL_READONLY: postgres://test_user:test_password@localhost:5433/packrat_test + # Other test environment variables (from test setup) + ENVIRONMENT: development + SENTRY_DSN: https://test@test.ingest.sentry.io/test + JWT_SECRET: secret + PASSWORD_RESET_SECRET: secret + GOOGLE_CLIENT_ID: test-client-id + ADMIN_USERNAME: admin + ADMIN_PASSWORD: admin-password + PACKRAT_API_KEY: test-api-key + EMAIL_PROVIDER: resend + RESEND_API_KEY: key + EMAIL_FROM: test@example.com + OPENAI_API_KEY: sk-test-key + AI_PROVIDER: openai + PERPLEXITY_API_KEY: pplx-test-key + OPENWEATHER_KEY: test-weather-key + WEATHER_API_KEY: test-weather-key run: bun test --cwd packages/api \ No newline at end of file diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index be2f3c6009..390d26c444 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -6,7 +6,7 @@ export default (): ExpoConfig => { name: 'PackRat', slug: 'packrat', - version: '2.0.4', + version: '2.0.5', scheme: 'packrat', web: { bundler: 'metro', diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index fc806a5dfb..66385f6070 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -1,11 +1,12 @@ import { type UIMessage, useChat } from '@ai-sdk/react'; -import { Button, Text } from '@packrat/ui/nativewindui'; +import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { FlashList } from '@shopify/flash-list'; import { DefaultChatTransport, type TextUIPart } from 'ai'; import { fetch as expoFetch } from 'expo/fetch'; import { clientEnvs } from 'expo-app/env/clientEnvs'; import { ChatBubble } from 'expo-app/features/ai/components/ChatBubble'; +import { ErrorState } from 'expo-app/features/ai/components/ErrorState'; import { tokenAtom } from 'expo-app/features/auth/atoms/authAtoms'; import { LocationSelector } from 'expo-app/features/weather/components/LocationSelector'; import { useActiveLocation } from 'expo-app/features/weather/hooks'; @@ -17,7 +18,6 @@ import { useAtomValue } from 'jotai'; import * as React from 'react'; import { useEffect } from 'react'; import { - Alert, Dimensions, Keyboard, type NativeSyntheticEvent, @@ -72,9 +72,7 @@ export default function AIChat() { const insets = useSafeAreaInsets(); const { progress } = useReanimatedKeyboardAnimation(); const textInputHeight = useSharedValue(17); - const translateX = useSharedValue(0); const params = useLocalSearchParams(); - const [showSuggestions, setShowSuggestions] = React.useState(true); const { activeLocation } = useActiveLocation(); const listRef = React.useRef>(null); @@ -92,7 +90,9 @@ export default function AIChat() { const token = useAtomValue(tokenAtom); const [input, setInput] = React.useState(''); - const { messages, error, sendMessage, status } = useChat({ + const [lastUserMessage, setLastUserMessage] = React.useState(''); + const [previousMessages, setPreviousMessages] = React.useState([]); + const { messages, setMessages, error, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ fetch: expoFetch as unknown as typeof globalThis.fetch, api: `${clientEnvs.EXPO_PUBLIC_API_URL}/api/chat`, @@ -114,29 +114,21 @@ export default function AIChat() { parts: [{ type: 'text', text: getContextualGreeting(context) }], } as UIMessage, ], - onFinish: () => { - // Hide suggestions after user sends a message - setShowSuggestions(false); - }, }); - const isLoading = status === 'streaming'; + const isLoading = status === 'submitted' || status === 'streaming'; - const handleSubmit = () => { - sendMessage({ text: input }); + const handleSubmit = (text?: string) => { + const messageText = text || input; + setLastUserMessage(messageText); + setPreviousMessages(messages); + sendMessage({ text: messageText }); setInput(''); }; - React.useEffect(() => { - if (error) { - Alert.alert(error.message); - } - }, [error]); - - const handleSuggestionPress = (suggestion: string) => { - sendMessage({ text: suggestion }); - setInput(''); - setShowSuggestions(false); + const handleRetry = () => { + setMessages(previousMessages); + sendMessage({ text: lastUserMessage }); }; const toolbarHeightStyle = useAnimatedStyle(() => ({ @@ -174,7 +166,6 @@ export default function AIChat() { behavior="padding" > - {showSuggestions && messages.length <= 2 && ( + {messages.length < 2 && ( SUGGESTIONS {getContextualSuggestions(context).map((suggestion) => ( handleSuggestionPress(suggestion)} + onPress={() => handleSubmit(suggestion)} className="mb-2 rounded-full border border-border bg-card px-3 py-2" > {suggestion} @@ -209,6 +200,14 @@ export default function AIChat() { )} + {status === 'submitted' && ( + + )} + {status === 'error' && handleRetry()} />} } @@ -224,10 +223,10 @@ export default function AIChat() { let userQuery: TextUIPart['text'] | undefined; if (item.role === 'assistant' && index > 1) { const userMessage = messages[index - 1]; - userQuery = userMessage.parts.find((p) => p.type === 'text')?.text; + userQuery = userMessage?.parts.find((p) => p.type === 'text')?.text; } - return ; + return ; }} /> @@ -238,7 +237,6 @@ export default function AIChat() { handleInputChange={setInput} // Pass the setter directly. handleSubmit={() => { handleSubmit(); - setShowSuggestions(false); }} isLoading={isLoading} placeholder={ @@ -332,9 +330,7 @@ function Composer({ /> {isLoading ? ( - - ... - + ) : input.length > 0 ? ( + + + ); +} diff --git a/apps/expo/features/ai/components/PackDetailsGenerativeUI.tsx b/apps/expo/features/ai/components/PackDetailsGenerativeUI.tsx index bfbfd90d44..415f37a45f 100644 --- a/apps/expo/features/ai/components/PackDetailsGenerativeUI.tsx +++ b/apps/expo/features/ai/components/PackDetailsGenerativeUI.tsx @@ -1,33 +1,13 @@ import { Icon, type MaterialIconName } from '@roninoss/icons'; +import type { Pack as BasePack, PackItem as BasePackItem } from 'expo-app/features/packs/types'; import { router } from 'expo-router'; import { Pressable, ScrollView, Text, View } from 'react-native'; -interface PackItem { - id: string; - name: string; - weight?: number; - category?: string; -} +// Use the shared types from packs/types with some overrides for compatibility +type PackItem = Pick; -interface Pack { - id: string; - name: string; - description: string; - category: string; - userId: number; - templateId: string | null; - isPublic: boolean; - image: string | null; - tags: string[]; - deleted: boolean; - localCreatedAt: string; - localUpdatedAt: string; - createdAt: string; - updatedAt: string; +interface Pack extends Omit { items: PackItem[]; - baseWeight: number; - totalWeight: number; - categories: string[]; } interface PackDetailsGenerativeUIProps { @@ -98,7 +78,7 @@ export function PackDetailsGenerativeUI({ pack }: PackDetailsGenerativeUIProps) )} {/* Tags */} - {pack.tags.length > 0 && ( + {pack.tags && pack.tags.length > 0 && ( {pack.tags.map((tag) => ( @@ -178,11 +158,15 @@ export function PackDetailsGenerativeUI({ pack }: PackDetailsGenerativeUIProps) Created - {formatDate(pack.createdAt)} + + {formatDate(pack.createdAt || pack.localCreatedAt)} + Updated - {formatDate(pack.updatedAt)} + + {formatDate(pack.updatedAt || pack.localUpdatedAt)} + diff --git a/apps/expo/features/ai/components/PackItemDetailsGenerativeUI.tsx b/apps/expo/features/ai/components/PackItemDetailsGenerativeUI.tsx index dff5e0407d..d6da263017 100644 --- a/apps/expo/features/ai/components/PackItemDetailsGenerativeUI.tsx +++ b/apps/expo/features/ai/components/PackItemDetailsGenerativeUI.tsx @@ -1,28 +1,12 @@ import { Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import type { CatalogItem } from 'expo-app/features/catalog/types'; +import type { PackItem as BasePackItem } from 'expo-app/features/packs/types'; import { View } from 'react-native'; -interface PackItem { - id: string; - name: string; - description: string; - weight: number; - weightUnit: string; - quantity: number; - category: string; - consumable: boolean; - worn: boolean; - image: string | null; - notes: string; - packId: string; - catalogItemId: string | null; - userId: number; - deleted: boolean; - templateItemId: string | null; - createdAt: string; - updatedAt: string; - pack: { +interface PackItem extends BasePackItem { + templateItemId?: string | null; + pack?: { id: string; name: string; description: string; @@ -38,7 +22,7 @@ interface PackItem { createdAt: string; updatedAt: string; }; - catalogItem: CatalogItem | null; + catalogItem?: CatalogItem | null; } interface PackItemDetailsGenerativeUIProps { @@ -156,11 +140,11 @@ export function PackItemDetailsGenerativeUI({ item }: PackItemDetailsGenerativeU Created - {formatDate(item.createdAt)} + {formatDate(String(item.createdAt))} Updated - {formatDate(item.updatedAt)} + {formatDate(String(item.updatedAt))} diff --git a/apps/expo/features/ai/components/ToolInvocationRenderer.tsx b/apps/expo/features/ai/components/ToolInvocationRenderer.tsx index 2da6aa5613..83986d6c72 100644 --- a/apps/expo/features/ai/components/ToolInvocationRenderer.tsx +++ b/apps/expo/features/ai/components/ToolInvocationRenderer.tsx @@ -1,4 +1,7 @@ import type { ToolUIPart } from 'ai'; +import type { CatalogItem } from 'expo-app/features/catalog/types'; +import type { PackItem } from 'expo-app/features/packs'; +import type { Pack } from 'expo-app/features/packs/types'; import { CatalogItemsGenerativeUI } from './CatalogItemsGenerativeUI'; import { GuidesRAGGenerativeUI } from './GuidesRAGGenerativeUI'; import { PackDetailsGenerativeUI } from './PackDetailsGenerativeUI'; @@ -6,6 +9,76 @@ import { PackItemDetailsGenerativeUI } from './PackItemDetailsGenerativeUI'; import { WeatherGenerativeUI } from './WeatherGenerativeUI'; import { WebSearchGenerativeUI } from './WebSearchGenerativeUI'; +interface WeatherData { + success: boolean; + location: string; + temperature: number; + conditions: string; + humidity: number; + windSpeed: number; +} + +interface WebSearchData { + query: string; + answer: string; + sources: Array<{ + type: string; + sourceType: string; + id: string; + url: string; + }>; + success: boolean; +} + +interface GuideSearchResult { + file_id: string; + filename: string; + score: number; + attributes: { + timestamp: number; + folder: string; + filename: string; + }; + content: Array<{ + id: string; + type: string; + text: string; + }>; + url: string; +} + +interface GuidesSearchResultsData { + object: string; + search_query: string; + data: GuideSearchResult[]; + has_more: boolean; + next_page: string | null; +} + +interface CatalogSearchResult { + success: boolean; + data?: { + items: CatalogItem[]; + total: number; + limit: number; + }; +} + +interface PackDetailsResult { + success: boolean; + pack?: Pack; +} + +interface PackItemDetailsResult { + success: boolean; + item?: PackItem; +} + +interface RAGSearchResult { + success: boolean; + results?: GuidesSearchResultsData; +} + interface ToolInvocationRendererProps { toolInvocation: ToolUIPart; } @@ -19,43 +92,135 @@ export function ToolInvocationRenderer({ toolInvocation }: ToolInvocationRendere const { type: toolName, input: args, output: result } = toolInvocation; // Handle getWeatherForLocation tool result - if (toolName === 'tool-getWeatherForLocation') { + if (toolName === 'tool-getWeatherForLocation' && isWeatherArgs(args) && isWeatherData(result)) { return ; } // Handle getCatalogItems tool result if ( (toolName === 'tool-getCatalogItems' || toolName === 'tool-semanticCatalogSearch') && - result.success && - result.data + isCatalogSearchResult(result) ) { - return ( - - ); + if (result.success && result.data) { + return ( + + ); + } } // Handle searchPackratOutdoorGuidesRAG tool result - if (toolName === 'tool-searchPackratOutdoorGuidesRAG' && result.success && result.results) { - return ; + if ( + toolName === 'tool-searchPackratOutdoorGuidesRAG' && + isQueryArgs(args) && + isRAGSearchResult(result) + ) { + if (result.success && result.results) { + return ; + } } // Handle getPackDetails tool result - if (toolName === 'tool-getPackDetails' && result.success && result.pack) { - return ; + if (toolName === 'tool-getPackDetails' && isPackDetailsResult(result)) { + if (result.success && result.pack) { + return ; + } } // Handle getPackItemDetails tool result - if (toolName === 'tool-getPackItemDetails' && result.success && result.item) { - return ; + if (toolName === 'tool-getPackItemDetails' && isPackItemDetailsResult(result)) { + if (result.success && result.item) { + return ; + } } - if (toolName === 'tool-webSearchTool' && result.success) { - return ; + if (toolName === 'tool-webSearchTool' && isQueryArgs(args) && isWebSearchData(result)) { + if (result.success) { + return ; + } } return null; } + +// Type guard functions +function isWeatherArgs(args: unknown): args is { location: string } { + return ( + typeof args === 'object' && + args !== null && + 'location' in args && + typeof (args as { location: unknown }).location === 'string' + ); +} + +function isWeatherData(result: unknown): result is WeatherData { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + 'location' in result && + 'temperature' in result && + 'conditions' in result && + 'humidity' in result && + 'windSpeed' in result + ); +} + +function isQueryArgs(args: unknown): args is { query: string } { + return ( + typeof args === 'object' && + args !== null && + 'query' in args && + typeof (args as { query: unknown }).query === 'string' + ); +} + +function isCatalogSearchResult(result: unknown): result is CatalogSearchResult { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + typeof (result as { success: unknown }).success === 'boolean' + ); +} + +function isRAGSearchResult(result: unknown): result is RAGSearchResult { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + typeof (result as { success: unknown }).success === 'boolean' + ); +} + +function isPackDetailsResult(result: unknown): result is PackDetailsResult { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + typeof (result as { success: unknown }).success === 'boolean' + ); +} + +function isPackItemDetailsResult(result: unknown): result is PackItemDetailsResult { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + typeof (result as { success: unknown }).success === 'boolean' + ); +} + +function isWebSearchData(result: unknown): result is WebSearchData { + return ( + typeof result === 'object' && + result !== null && + 'success' in result && + 'query' in result && + 'answer' in result && + 'sources' in result + ); +} diff --git a/apps/expo/features/auth/atoms/authAtoms.ts b/apps/expo/features/auth/atoms/authAtoms.ts index b3106ba918..9c9e8fdeef 100644 --- a/apps/expo/features/auth/atoms/authAtoms.ts +++ b/apps/expo/features/auth/atoms/authAtoms.ts @@ -1,4 +1,4 @@ -import * as SecureStore from 'expo-secure-store'; +import Storage from 'expo-sqlite/kv-store'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; @@ -13,25 +13,21 @@ export type User = { // Token storage atom export const tokenAtom = atomWithStorage('access_token', null, { - getItem: async (key) => await SecureStore.getItemAsync(key), + getItem: async (key) => await Storage.getItem(key), setItem: async (key, value) => { - if (value === null) { - return await SecureStore.deleteItemAsync(key); - } - return await SecureStore.setItemAsync(key, value); + if (value === null) return Storage.removeItem(key); + return Storage.setItem(key, value); }, - removeItem: async (key) => SecureStore.deleteItemAsync(key), + removeItem: async (key) => Storage.removeItem(key), }); export const refreshTokenAtom = atomWithStorage('refresh_token', null, { - getItem: async (key) => await SecureStore.getItemAsync(key), + getItem: async (key) => await Storage.getItem(key), setItem: async (key, value) => { - if (value === null) { - return await SecureStore.deleteItemAsync(key); - } - return await SecureStore.setItemAsync(key, value); + if (value === null) return Storage.removeItem(key); + return Storage.setItem(key, value); }, - removeItem: async (key) => SecureStore.deleteItemAsync(key), + removeItem: async (key) => Storage.removeItem(key), }); // Loading state atom diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index 00cc35ee02..e857cdb79a 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -12,8 +12,9 @@ import axiosInstance from 'expo-app/lib/api/client'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import * as AppleAuthentication from 'expo-apple-authentication'; import { type Href, router } from 'expo-router'; -import * as SecureStore from 'expo-secure-store'; +import Storage from 'expo-sqlite/kv-store'; import { useAtomValue, useSetAtom } from 'jotai'; + import { isLoadingAtom, redirectToAtom, refreshTokenAtom, tokenAtom } from '../atoms/authAtoms'; function redirect(route: string) { @@ -33,8 +34,8 @@ export function useAuthActions() { const clearLocalData = async () => { // Clear tokens from secure storage - await SecureStore.deleteItemAsync('access_token'); - await SecureStore.deleteItemAsync('refresh_token'); + await Storage.removeItem('access_token'); + await Storage.removeItem('refresh_token'); // Clear state await setToken(null); @@ -65,8 +66,8 @@ export function useAuthActions() { console.log(data.accessToken, data.refreshToken); // Store both tokens - await SecureStore.setItemAsync('access_token', data.accessToken); - await SecureStore.setItemAsync('refresh_token', data.refreshToken); + await Storage.setItem('access_token', data.accessToken); + await Storage.setItem('refresh_token', data.refreshToken); await setToken(data.accessToken); await setRefreshToken(data.refreshToken); @@ -113,9 +114,8 @@ export function useAuthActions() { } // Store both tokens - await SecureStore.setItemAsync('access_token', data.accessToken); - await SecureStore.setItemAsync('refresh_token', data.refreshToken); - + await Storage.setItem('access_token', data.accessToken); + await Storage.setItem('refresh_token', data.refreshToken); await setToken(data.accessToken); await setRefreshToken(data.refreshToken); userStore.set(data.user); @@ -172,8 +172,8 @@ export function useAuthActions() { } // Store both tokens - await SecureStore.setItemAsync('access_token', data.accessToken); - await SecureStore.setItemAsync('refresh_token', data.refreshToken); + await Storage.setItem('access_token', data.accessToken); + await Storage.setItem('refresh_token', data.refreshToken); await setToken(data.accessToken); await setRefreshToken(data.refreshToken); @@ -221,8 +221,7 @@ export function useAuthActions() { } // Get the refresh token - const refreshToken = await SecureStore.getItemAsync('refresh_token'); - + const refreshToken = await Storage.getItem('refresh_token'); if (refreshToken) { // Call the logout endpoint to revoke the refresh token await fetch(`${clientEnvs.EXPO_PUBLIC_API_URL}/api/auth/logout`, { @@ -306,8 +305,8 @@ export function useAuthActions() { // If verification is successful, set the user and tokens if (data.accessToken && data.refreshToken && data.user) { - await SecureStore.setItemAsync('access_token', data.accessToken); - await SecureStore.setItemAsync('refresh_token', data.refreshToken); + await Storage.setItem('access_token', data.accessToken); + await Storage.setItem('refresh_token', data.refreshToken); await setToken(data.accessToken); await setRefreshToken(data.refreshToken); diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 0f01a118ea..70e6a214bc 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; import { clientEnvs } from 'expo-app/env/clientEnvs'; import { router } from 'expo-router'; -import * as SecureStore from 'expo-secure-store'; +import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; import { isAuthed } from '../store'; @@ -33,7 +33,7 @@ export function useAuthInit() { const hasSkippedLogin = await AsyncStorage.getItem('skipped_login'); // Get stored token - const accessToken = await SecureStore.getItemAsync('access_token'); + const accessToken = await Storage.getItem('access_token'); // If user has session or hasSkippedLogin before, continue to app if (accessToken || hasSkippedLogin === 'true') { diff --git a/apps/expo/features/auth/store/user.ts b/apps/expo/features/auth/store/user.ts index 2de22c3846..2fd01d5ec8 100644 --- a/apps/expo/features/auth/store/user.ts +++ b/apps/expo/features/auth/store/user.ts @@ -2,8 +2,8 @@ import { observable, syncState } from '@legendapp/state'; import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; +import type { User } from 'expo-app/features/profile/types'; import Storage from 'expo-sqlite/kv-store'; -import type { User } from '../../profile/types'; export const userStore = observable(null); diff --git a/apps/expo/features/catalog/lib/cacheCatalogItemImage.ts b/apps/expo/features/catalog/lib/cacheCatalogItemImage.ts new file mode 100644 index 0000000000..af111399f5 --- /dev/null +++ b/apps/expo/features/catalog/lib/cacheCatalogItemImage.ts @@ -0,0 +1,17 @@ +import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; + +export async function cacheCatalogItemImage(imageUrl?: string): Promise { + if (!imageUrl) { + return null; + } + + try { + // Generate a filename from the URL + const fileName = imageUrl.split('/').pop() || `image_${Date.now()}.jpg`; + const filename = await ImageCacheManager.cacheRemoteImage(fileName, imageUrl); + return filename; + } catch (err) { + console.log('caching remote image failed', err); + return null; + } +} diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 2b0bce1469..e53a74364c 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -23,6 +23,7 @@ import { } from 'react-native'; import { useCatalogItemDetails } from '../hooks'; +import { cacheCatalogItemImage } from '../lib/cacheCatalogItemImage'; export function AddCatalogItemDetailsScreen() { const router = useRouter(); @@ -36,6 +37,7 @@ export function AddCatalogItemDetailsScreen() { const pack = usePackDetailsFromStore(packId as string); const createItem = useCreatePackItem(); const fadeAnim = useState(new Animated.Value(0))[0]; + const [isAdding, setIsAdding] = useState(false); // Form state const [quantity, setQuantity] = useState('1'); @@ -65,8 +67,12 @@ export function AddCatalogItemDetailsScreen() { } }, [catalogItem]); - const handleAddToPack = () => { + const handleAddToPack = async () => { + setIsAdding(true); assertDefined(catalogItem); + + const cachedImageFilename = await cacheCatalogItemImage(catalogItem.images?.[0]); + createItem({ packId: packId as string, itemData: { @@ -79,12 +85,11 @@ export function AddCatalogItemDetailsScreen() { consumable: isConsumable, worn: isWorn, notes, - image: Array.isArray(catalogItem.images) - ? catalogItem.images[0] - : catalogItem.images || undefined, + image: cachedImageFilename, catalogItemId: catalogItem.id, }, }); + setIsAdding(false); // Navigate back to the catalog item detail screen router.dismissTo({ pathname: '/catalog/[id]', @@ -165,6 +170,7 @@ export function AddCatalogItemDetailsScreen() { params: { catalogItemId }, }) } + disabled={isAdding} > Change @@ -248,13 +254,17 @@ export function AddCatalogItemDetailsScreen() { - - diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index cc379c0ad1..6b818ef911 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -6,13 +6,16 @@ import { ExpandableText } from 'expo-app/components/initial/ExpandableText'; import { ItemLinks } from 'expo-app/features/catalog/components/ItemLinks'; import { ItemReviews } from 'expo-app/features/catalog/components/ItemReviews'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { ErrorScreen } from 'expo-app/screens/ErrorScreen'; +import { LoadingSpinnerScreen } from 'expo-app/screens/LoadingSpinnerScreen'; +import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen'; +import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { Image, Linking, Platform, SafeAreaView, ScrollView, View } from 'react-native'; -import { ErrorScreen } from '../../../screens/ErrorScreen'; -import { LoadingSpinnerScreen } from '../../../screens/LoadingSpinnerScreen'; -import { NotFoundScreen } from '../../../screens/NotFoundScreen'; +import { Linking, Platform, SafeAreaView, ScrollView, View } from 'react-native'; import { useCatalogItemDetails } from '../hooks'; +const fallbackImage = require('expo-app/assets/image-not-available.png'); + export function CatalogItemDetailScreen() { const router = useRouter(); const { id } = useLocalSearchParams(); @@ -53,22 +56,31 @@ export function CatalogItemDetailScreen() { return ( - + + + @@ -184,11 +196,11 @@ export function CatalogItemDetailScreen() { Specifications - + {Object.entries(item.techs).map(([key, value]) => ( - + {key} - {value} + {value} ))} diff --git a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx index a0a242d7ee..009e6e9ddc 100644 --- a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx @@ -24,6 +24,17 @@ import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories'; import { useVectorSearch } from '../hooks/useVectorSearch'; import type { CatalogItem } from '../types'; +function renderSearchPlaceholder(iosHideWhenScrolling: boolean) { + if (iosHideWhenScrolling) { + return null; // don’t show on iOS + } + return ( + + Search catalog + + ); +} + function CatalogItemsScreen() { const router = useRouter(); const { colors } = useColorScheme(); @@ -144,9 +155,7 @@ function CatalogItemsScreen() { ) ) : ( - - Search catalog - + renderSearchPlaceholder(true) ), }} /> diff --git a/apps/expo/features/catalog/screens/PackSelectionScreen.tsx b/apps/expo/features/catalog/screens/PackSelectionScreen.tsx index 39fd30b13e..d88e7171b6 100644 --- a/apps/expo/features/catalog/screens/PackSelectionScreen.tsx +++ b/apps/expo/features/catalog/screens/PackSelectionScreen.tsx @@ -59,7 +59,7 @@ export function PackSelectionScreen() { - {catalogItem.defaultWeight} {catalogItem.defaultWeightUnit} + {catalogItem.weight} {catalogItem.weightUnit} {catalogItem.brand && ( <> diff --git a/apps/expo/features/guides/screens/GuideDetailScreen.tsx b/apps/expo/features/guides/screens/GuideDetailScreen.tsx index cdf1e18301..5d25818655 100644 --- a/apps/expo/features/guides/screens/GuideDetailScreen.tsx +++ b/apps/expo/features/guides/screens/GuideDetailScreen.tsx @@ -1,5 +1,5 @@ import { Text } from '@packrat/ui/nativewindui'; -import Markdown from '@ronradtke/react-native-markdown-display'; +import { Markdown } from 'expo-app/components/Markdown'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useLocalSearchParams, useNavigation } from 'expo-router'; import { useLayoutEffect } from 'react'; @@ -37,99 +37,6 @@ export const GuideDetailScreen = () => { ); } - const markdownStyles = { - body: { - color: colors.foreground, - fontSize: 16, - }, - heading1: { - color: colors.foreground, - fontSize: 28, - fontWeight: '700', - marginTop: 24, - marginBottom: 16, - }, - heading2: { - color: colors.foreground, - fontSize: 24, - fontWeight: '600', - marginTop: 20, - marginBottom: 12, - }, - heading3: { - color: colors.foreground, - fontSize: 20, - fontWeight: '600', - marginTop: 16, - marginBottom: 8, - }, - paragraph: { - marginTop: 0, - marginBottom: 16, - }, - strong: { - fontWeight: '600' as const, - }, - link: { - color: colors.primary, - textDecorationLine: 'underline' as const, - }, - blockquote: { - backgroundColor: colors.grey6, - borderLeftColor: colors.primary, - borderLeftWidth: 4, - paddingLeft: 16, - paddingVertical: 8, - marginVertical: 16, - }, - code_inline: { - backgroundColor: colors.grey6, - color: colors.primary, - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - fontFamily: 'monospace', - fontSize: 14, - }, - code_block: { - backgroundColor: colors.card, - color: colors.foreground, - padding: 16, - borderRadius: 8, - marginVertical: 16, - fontFamily: 'monospace', - fontSize: 14, - }, - list_item: { - marginBottom: 8, - }, - bullet_list: { - marginBottom: 16, - }, - ordered_list: { - marginBottom: 16, - }, - hr: { - backgroundColor: colors.grey5, - height: 1, - marginVertical: 24, - }, - table: { - borderColor: colors.grey5, - marginVertical: 16, - }, - th: { - backgroundColor: colors.grey6, - color: colors.foreground, - fontWeight: '600', - padding: 12, - }, - td: { - padding: 12, - borderColor: colors.grey5, - }, - }; - return ( { {guide.description} )} - {guide.content || ''} + {guide.content || ''} ); }; diff --git a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx index 4f2b688190..d06b001353 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx @@ -1,14 +1,12 @@ -// components/PackTemplateItemCard.tsx - -import { Alert, Button } from '@packrat/ui/nativewindui'; +import { Alert, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; -import { CachedImage } from 'expo-app/features/packs/components/CachedImage'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; import { useRouter } from 'expo-router'; -import { Pressable, Text, View } from 'react-native'; +import { Image, Pressable, View } from 'react-native'; import { useDeletePackTemplateItem } from '../hooks/useDeletePackTemplateItem'; import type { PackTemplateItem } from '../types'; @@ -28,12 +26,20 @@ export function PackTemplateItemCard({ const { colors } = useColorScheme(); const user = useUser(); + const imageUrl = buildPackTemplateItemImageUrl(item.image); + return ( onPress(item)} > - + {imageUrl ? ( + + ) : ( + + No image + + )} diff --git a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx index 64e1bc5454..adc3a6401f 100644 --- a/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx +++ b/apps/expo/features/pack-templates/screens/CreatePackTemplateItemForm.tsx @@ -25,7 +25,7 @@ import { import { z } from 'zod'; import { useCreatePackTemplateItem } from '../hooks/useCreatePackTemplateItem'; import { useUpdatePackTemplateItem } from '../hooks/useUpdatePackTemplateItem'; -import type { PackTemplateItem } from '../types'; +import type { PackTemplateItem, PackTemplateItemInput } from '../types'; const itemFormSchema = z.object({ name: z.string().min(1, 'Item name is required'), @@ -74,20 +74,20 @@ export const CreatePackTemplateItemForm = ({ name: '', description: '', weight: 0, - weightUnit: 'g', - quantity: 0, + weightUnit: 'g' as WeightUnit, + quantity: 1, category: '', consumable: false, worn: false, notes: '', image: null, }, - validators: { - onChange: itemFormSchema, - }, onSubmit: async ({ value }) => { try { - let imageUrl = value.image; + // Validate the form data before processing + const validatedData = itemFormSchema.parse(value); + + let imageUrl = validatedData.image; const oldImageUrl = initialImageUrl.current; if (selectedImage) { @@ -96,13 +96,18 @@ export const CreatePackTemplateItemForm = ({ Alert.alert('Error', 'Failed to save item image. Please try again.'); return; } - value.image = imageUrl; + validatedData.image = imageUrl; } if (isEditing) { - updateItem({ id: existingItem.id, ...value }); + updateItem({ + id: existingItem.id, + packTemplateId: existingItem.packTemplateId, + deleted: existingItem.deleted, + ...(validatedData as PackTemplateItemInput), + }); } else { - createItem({ packTemplateId, itemData: value }); + createItem({ packTemplateId, itemData: validatedData as PackTemplateItemInput }); } if (isEditing && oldImageUrl && imageChanged) { @@ -177,7 +182,6 @@ export const CreatePackTemplateItemForm = ({ value={field.state.value} onBlur={field.handleBlur} onChangeText={field.handleChange} - errorMessage={field.state.meta.errors[0]?.message} leftView={ @@ -236,9 +240,8 @@ export const CreatePackTemplateItemForm = ({ placeholder="Weight" value={field.state.value.toString()} onBlur={field.handleBlur} - onChangeText={field.handleChange} + onChangeText={(text) => field.handleChange(Number(text) || 0)} keyboardType="numeric" - errorMessage={field.state.meta.errors[0]?.message} leftView={ @@ -256,9 +259,12 @@ export const CreatePackTemplateItemForm = ({ Unit { - field.handleChange(WEIGHT_UNITS[index]); + const selectedUnit = WEIGHT_UNITS[index]; + if (selectedUnit) { + field.handleChange(selectedUnit); + } }} /> @@ -278,7 +284,6 @@ export const CreatePackTemplateItemForm = ({ field.handleChange(intValue); }} keyboardType="numeric" - errorMessage={field.state.meta.errors[0]?.message} leftView={ diff --git a/apps/expo/features/pack-templates/screens/EditPackTemplateItemScreen.tsx b/apps/expo/features/pack-templates/screens/EditPackTemplateItemScreen.tsx index 7b6ee9b1fd..b52ebbf8ee 100644 --- a/apps/expo/features/pack-templates/screens/EditPackTemplateItemScreen.tsx +++ b/apps/expo/features/pack-templates/screens/EditPackTemplateItemScreen.tsx @@ -1,5 +1,5 @@ +import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen'; import { useLocalSearchParams } from 'expo-router'; -import { NotFoundScreen } from '../../../screens/NotFoundScreen'; import { usePackTemplateItem } from '../hooks/usePackTemplateItem'; import { CreatePackTemplateItemForm } from './CreatePackTemplateItemForm'; diff --git a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx index e68190b37e..b09dfa1ed8 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx @@ -1,9 +1,9 @@ -import { Button } from '@packrat/ui/nativewindui'; +import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { isAuthed } from 'expo-app/features/auth/store'; -import { CachedImage } from 'expo-app/features/packs/components/CachedImage'; +import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; import { calculateTotalWeight, getNotes, @@ -15,7 +15,7 @@ import { } from 'expo-app/lib/utils/itemCalculations'; import { assertDefined } from 'expo-app/utils/typeAssertions'; import { router, useLocalSearchParams } from 'expo-router'; -import { SafeAreaView, ScrollView, Text, View } from 'react-native'; +import { Image, SafeAreaView, ScrollView, View } from 'react-native'; import { usePackTemplateItem } from '../hooks/usePackTemplateItem'; export function PackTemplateItemDetailScreen() { @@ -62,10 +62,18 @@ export function PackTemplateItemDetailScreen() { }); }; + const imageUrl = buildPackTemplateItemImageUrl(item.image); + return ( - + {imageUrl ? ( + + ) : ( + + No image + + )} {item.name} diff --git a/apps/expo/features/packs/components/CachedImage.tsx b/apps/expo/features/packs/components/CachedImage.tsx index 6187a4977a..4f18ce8182 100644 --- a/apps/expo/features/packs/components/CachedImage.tsx +++ b/apps/expo/features/packs/components/CachedImage.tsx @@ -1,65 +1,60 @@ -import { Text } from '@packrat/ui/nativewindui'; -import { clientEnvs } from 'expo-app/env/clientEnvs'; -import { useUser } from 'expo-app/features/auth/hooks/useUser'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import type React from 'react'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Image, type ImageProps, View } from 'react-native'; interface CachedImageProps extends Omit { - localFileName?: string | null; + imageObjectKey: string; + imageRemoteUrl: string; placeholderColor?: string; } +/** + * CachedImage + * + * Responsible for displaying user-owned item images. + * Loads from local cache if available, otherwise downloads and caches the image + * before displaying. Shows loading indicator while fetching. + */ export const CachedImage: React.FC = ({ - localFileName, - className, + imageObjectKey, + imageRemoteUrl, placeholderColor = '#e1e1e1', ...props }) => { - const [imageUri, setImageUri] = useState(null); + const [imageLocalUri, setImageLocalUri] = useState(null); const [loading, setLoading] = useState(true); - const user = useUser(); - const remoteFileName = `${user?.id}-${localFileName}`; - const remoteUrl = `${clientEnvs.EXPO_PUBLIC_R2_PUBLIC_URL}/${remoteFileName}`; + console.log(imageLocalUri); useEffect(() => { - if (!localFileName) return; + if (!imageObjectKey) return; const loadImage = async () => { try { setLoading(true); - const localUri = await ImageCacheManager.getCachedImageUri(localFileName); + const localUri = await ImageCacheManager.getCachedImageUri(imageObjectKey); if (localUri) { - setImageUri(localUri); + setImageLocalUri(localUri); } else { - const localUri = await ImageCacheManager.cacheRemoteImage(localFileName, remoteUrl); - setImageUri(localUri); + const localUri = await ImageCacheManager.cacheRemoteImage(imageObjectKey, imageRemoteUrl); + setImageLocalUri(localUri); } } catch (error) { console.error('Error loading image:', error); - // Fallback to remote URL on error - setImageUri(remoteUrl); + // TODO: Handle error state if needed } finally { setLoading(false); } }; loadImage(); - }, [localFileName, remoteUrl]); - - if (!localFileName) - return ( - - No image - - ); + }, [imageObjectKey, imageRemoteUrl]); if (loading) { return ( @@ -67,5 +62,5 @@ export const CachedImage: React.FC = ({ ); } - return ; + return ; }; diff --git a/apps/expo/features/packs/components/ItemSuggestionCard.tsx b/apps/expo/features/packs/components/ItemSuggestionCard.tsx index 56b473a9a7..d21f87287a 100644 --- a/apps/expo/features/packs/components/ItemSuggestionCard.tsx +++ b/apps/expo/features/packs/components/ItemSuggestionCard.tsx @@ -1,11 +1,9 @@ import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; +import { cacheCatalogItemImage } from 'expo-app/features/catalog/lib/cacheCatalogItemImage'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; -import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import { getImageExtension } from 'expo-app/lib/utils/imageUtils'; -import { nanoid } from 'nanoid/non-secure'; import { useState } from 'react'; import { Platform, View } from 'react-native'; import { useCreatePackItem } from '../hooks'; @@ -24,17 +22,8 @@ export function ItemSuggestionCard({ packId, item }: ItemSuggestionCardProps) { const handleAddItem = async (item: CatalogItem) => { setIsAdding(true); - let imageFileName: string | null = null; - if (item.images?.[0]) { - try { - const extension = await getImageExtension(item.images[0]); - const fileName = `${nanoid()}.${extension}`; - await ImageCacheManager.cacheRemoteImage(fileName, item.images[0]); - imageFileName = fileName; - } catch (err) { - console.log('caching remote image failed', err); - } - } + const cachedImageFilename = await cacheCatalogItemImage(item.images?.[0]); + // Create a new pack item from the catalog item const newItem: PackItemInput = { name: item.name, @@ -44,7 +33,7 @@ export function ItemSuggestionCard({ packId, item }: ItemSuggestionCardProps) { quantity: 1, consumable: false, worn: false, - image: imageFileName, + image: cachedImageFilename, notes: 'Suggested by PackRat AI', catalogItemId: item.id, }; diff --git a/apps/expo/features/packs/components/PackItemCard.tsx b/apps/expo/features/packs/components/PackItemCard.tsx index b613a3a049..804276ac3b 100644 --- a/apps/expo/features/packs/components/PackItemCard.tsx +++ b/apps/expo/features/packs/components/PackItemCard.tsx @@ -1,7 +1,6 @@ import { Alert, Button } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; -import { CachedImage } from 'expo-app/features/packs/components/CachedImage'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { assertDefined } from 'expo-app/utils/typeAssertions'; @@ -13,6 +12,7 @@ import { usePackItemOwnershipCheck, } from '../hooks'; import type { PackItem } from '../types'; +import { PackItemImage } from './PackItemImage'; type PackItemCardProps = { item: PackItem; @@ -34,7 +34,7 @@ export function PackItemCard({ item: itemArg, onPress }: PackItemCardProps) { className="mb-3 flex-row overflow-hidden rounded-lg bg-card shadow-sm" onPress={() => onPress(item)} > - + diff --git a/apps/expo/features/packs/components/PackItemImage.tsx b/apps/expo/features/packs/components/PackItemImage.tsx new file mode 100644 index 0000000000..1c6aa1fd50 --- /dev/null +++ b/apps/expo/features/packs/components/PackItemImage.tsx @@ -0,0 +1,29 @@ +import { Text } from '@packrat-ai/nativewindui'; +import { buildPackItemImageUrl } from 'expo-app/lib/utils/buildPackItemImageUrl'; +import { Image, type ImageProps, View } from 'react-native'; +import { usePackItemOwnershipCheck } from '../hooks'; +import type { PackItem } from '../types'; +import { CachedImage } from './CachedImage'; + +interface PackItemImageProps extends Omit { + item: PackItem; +} + +export function PackItemImage({ item, ...imageProps }: PackItemImageProps) { + const isItemOwnedByUser = usePackItemOwnershipCheck(item.id); + + if (!item.image) + return ( + + No image + + ); + + const imageUrl = buildPackItemImageUrl(item); + + if (isItemOwnedByUser) { + return ; + } else { + return ; + } +} diff --git a/apps/expo/features/packs/components/TemplateItemsSection.tsx b/apps/expo/features/packs/components/TemplateItemsSection.tsx index 00d630a35d..bc12712963 100644 --- a/apps/expo/features/packs/components/TemplateItemsSection.tsx +++ b/apps/expo/features/packs/components/TemplateItemsSection.tsx @@ -1,9 +1,9 @@ import { Icon } from '@roninoss/icons'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; import type { WeightUnit } from 'expo-app/types'; -import { ScrollView, Text, View } from 'react-native'; -import { CachedImage } from './CachedImage'; +import { Image, ScrollView, Text, View } from 'react-native'; export interface PackTemplateItem { id: string; @@ -44,11 +44,18 @@ const formatWeight = (weight: number, unit: string) => { // Template Item Card Component const TemplateItemCard = ({ item }: { item: PackTemplateItem }) => { const { colors } = useColorScheme(); + const imageUrl = buildPackTemplateItemImageUrl(item.image); return ( - + {imageUrl ? ( + + ) : ( + + No image + + )} {/* Item name */} diff --git a/apps/expo/features/packs/hooks/useCreatePackItem.ts b/apps/expo/features/packs/hooks/useCreatePackItem.ts index 3f57fdd433..b1624ebb9c 100644 --- a/apps/expo/features/packs/hooks/useCreatePackItem.ts +++ b/apps/expo/features/packs/hooks/useCreatePackItem.ts @@ -12,6 +12,7 @@ export function useCreatePackItem() { const newItem: PackItem = { id, ...itemData, + category: itemData.category || 'general', packId, deleted: false, }; diff --git a/apps/expo/features/packs/screens/CreatePackItemForm.tsx b/apps/expo/features/packs/screens/CreatePackItemForm.tsx index 5775fc1888..34b1321d6e 100644 --- a/apps/expo/features/packs/screens/CreatePackItemForm.tsx +++ b/apps/expo/features/packs/screens/CreatePackItemForm.tsx @@ -22,7 +22,7 @@ import { import { z } from 'zod'; import { useCreatePackItem, useUpdatePackItem } from '../hooks'; import { useImageUpload } from '../hooks/useImageUpload'; -import type { PackItem } from '../types'; +import type { PackItem, PackItemInput } from '../types'; // Define Zod schema const itemFormSchema = z.object({ @@ -101,12 +101,12 @@ export const CreatePackItemForm = ({ notes: '', image: null, }, - validators: { - onChange: itemFormSchema, - }, onSubmit: async ({ value }) => { try { - let imageUrl = value.image; + // Validate the form data before processing + const validatedData = itemFormSchema.parse(value); + + let imageUrl = validatedData.image; const oldImageUrl = initialImageUrl.current; // Permanently save the new image on users' device if one is selected - because selectedImage is currrently in temporary cache @@ -116,15 +116,15 @@ export const CreatePackItemForm = ({ Alert.alert('Error', 'Failed to save item image. Please try again.'); return; } - value.image = imageUrl; + validatedData.image = imageUrl; } // Submit the form with the image URL if (isEditing) { - updatePackItem({ ...existingItem, ...value }); + updatePackItem({ ...existingItem, ...(validatedData as PackItemInput) }); router.back(); } else { - createPackItem({ packId, itemData: value }); + createPackItem({ packId, itemData: validatedData as PackItemInput }); router.back(); } @@ -217,7 +217,6 @@ export const CreatePackItemForm = ({ value={field.state.value} onBlur={field.handleBlur} onChangeText={field.handleChange} - errorMessage={field.state.meta.errors[0]?.message} leftView={ @@ -282,9 +281,8 @@ export const CreatePackItemForm = ({ placeholder="Weight" value={field.state.value.toString()} onBlur={field.handleBlur} - onChangeText={field.handleChange} + onChangeText={(text) => field.handleChange(Number(text) || 0)} keyboardType="numeric" - errorMessage={field.state.meta.errors[0]?.message} leftView={ @@ -302,9 +300,12 @@ export const CreatePackItemForm = ({ Unit { - field.handleChange(WEIGHT_UNITS[index]); + const selectedUnit = WEIGHT_UNITS[index]; + if (selectedUnit) { + field.handleChange(selectedUnit); + } }} /> @@ -324,7 +325,6 @@ export const CreatePackItemForm = ({ field.handleChange(intValue); }} keyboardType="numeric" - errorMessage={field.state.meta.errors[0]?.message} leftView={ diff --git a/apps/expo/features/packs/screens/EditPackItemScreen.tsx b/apps/expo/features/packs/screens/EditPackItemScreen.tsx index d2c0709a08..6ccb165071 100644 --- a/apps/expo/features/packs/screens/EditPackItemScreen.tsx +++ b/apps/expo/features/packs/screens/EditPackItemScreen.tsx @@ -1,6 +1,6 @@ import { CreatePackItemForm } from 'expo-app/features/packs/screens/CreatePackItemForm'; +import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen'; import { useLocalSearchParams } from 'expo-router'; -import { NotFoundScreen } from '../../../screens/NotFoundScreen'; import { usePackItemDetailsFromStore } from '../hooks'; export function EditPackItemScreen() { diff --git a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx index f8dcabc8bf..8ed8262335 100644 --- a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx @@ -1,9 +1,8 @@ -import { ActivityIndicator, Button } from '@packrat/ui/nativewindui'; +import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; import { Icon } from '@roninoss/icons'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { isAuthed } from 'expo-app/features/auth/store'; -import { CachedImage } from 'expo-app/features/packs/components/CachedImage'; import { calculateTotalWeight, getNotes, @@ -14,7 +13,8 @@ import { shouldShowQuantity, } from 'expo-app/lib/utils/itemCalculations'; import { router, useLocalSearchParams } from 'expo-router'; -import { SafeAreaView, ScrollView, Text, View } from 'react-native'; +import { SafeAreaView, ScrollView, View } from 'react-native'; +import { PackItemImage } from '../components/PackItemImage'; import { usePackItemDetailsFromApi, usePackItemDetailsFromStore, @@ -56,7 +56,7 @@ export function ItemDetailScreen() { return ( - + @@ -120,7 +120,7 @@ export function ItemDetailScreen() { return ( - + {item.name} diff --git a/apps/expo/features/packs/types.ts b/apps/expo/features/packs/types.ts index db47276874..96da9eec93 100644 --- a/apps/expo/features/packs/types.ts +++ b/apps/expo/features/packs/types.ts @@ -63,9 +63,11 @@ export interface Pack { description?: string; category: PackCategory; userId?: number; + templateId?: string | null; isPublic: boolean; image?: string; tags?: string[]; + categories?: string[]; // For compatibility with some API responses items: PackItem[]; baseWeight: number; totalWeight: number; diff --git a/apps/expo/features/weather/atoms/locationsAtoms.ts b/apps/expo/features/weather/atoms/locationsAtoms.ts index 9fe8bb457b..52231d8c15 100644 --- a/apps/expo/features/weather/atoms/locationsAtoms.ts +++ b/apps/expo/features/weather/atoms/locationsAtoms.ts @@ -27,7 +27,7 @@ export const activeLocationAtom = atom( // Return null during loading or error states return null; }, - (get, set, newActiveId: string) => { + (get, set, newActiveId: number) => { const locationsResult = get(locationsAtom); if (locationsResult.state === 'hasData') { diff --git a/apps/expo/features/weather/components/LocationSelector.tsx b/apps/expo/features/weather/components/LocationSelector.tsx index 43a1d9e930..cd02b4512b 100644 --- a/apps/expo/features/weather/components/LocationSelector.tsx +++ b/apps/expo/features/weather/components/LocationSelector.tsx @@ -37,7 +37,7 @@ export function LocationSelector() { bottomSheetRef.current?.present(); }; - const handleSelectLocation = (locationId: string) => { + const handleSelectLocation = (locationId: number) => { setActiveLocation(locationId); bottomSheetRef.current?.close(); diff --git a/apps/expo/features/weather/hooks/useLocationRefresh.ts b/apps/expo/features/weather/hooks/useLocationRefresh.ts index 618d30f32d..f192cc5f2c 100644 --- a/apps/expo/features/weather/hooks/useLocationRefresh.ts +++ b/apps/expo/features/weather/hooks/useLocationRefresh.ts @@ -6,16 +6,13 @@ export function useLocationRefresh() { const [isRefreshing, setIsRefreshing] = useState(false); const { locationsState, updateLocation } = useLocations(); - const refreshLocation = async (locationId: string) => { + const refreshLocation = async (locationId: number) => { if (isRefreshing || locationsState.state !== 'hasData') return false; - const location = locationsState.data.find((loc) => loc.id === locationId); - if (!location) return false; - setIsRefreshing(true); try { - const weatherData = await getWeatherData(location.lat, location.lon); + const weatherData = await getWeatherData(locationId); if (weatherData) { const formattedData = formatWeatherData(weatherData); @@ -54,7 +51,7 @@ export function useLocationRefresh() { try { for (const location of locations) { try { - const weatherData = await getWeatherData(location.lat, location.lon); + const weatherData = await getWeatherData(location.id); if (weatherData) { const formattedData = formatWeatherData(weatherData); diff --git a/apps/expo/features/weather/hooks/useLocationSearch.ts b/apps/expo/features/weather/hooks/useLocationSearch.ts index 24d32343ef..63fcd5ca64 100644 --- a/apps/expo/features/weather/hooks/useLocationSearch.ts +++ b/apps/expo/features/weather/hooks/useLocationSearch.ts @@ -59,7 +59,7 @@ export function useLocationSearch() { try { // Get weather data for the selected location - const weatherData = await getWeatherData(result.lat, result.lon); + const weatherData = await getWeatherData(result.id); if (weatherData) { const formattedData = formatWeatherData(weatherData); diff --git a/apps/expo/features/weather/hooks/useLocations.ts b/apps/expo/features/weather/hooks/useLocations.ts index 25740a97dc..0be9178811 100644 --- a/apps/expo/features/weather/hooks/useLocations.ts +++ b/apps/expo/features/weather/hooks/useLocations.ts @@ -20,7 +20,7 @@ export function useLocations() { setBaseLocations([...locations, location]); }; - const removeLocation = (locationId: string) => { + const removeLocation = (locationId: number) => { if (locationsState.state !== 'hasData') return; const locations = locationsState.data; @@ -29,7 +29,7 @@ export function useLocations() { setBaseLocations(locations.filter((loc) => loc.id !== locationId)); }; - const updateLocation = (locationId: string, updates: Partial) => { + const updateLocation = (locationId: number, updates: Partial) => { if (locationsState.state !== 'hasData') return; const locations = locationsState.data; diff --git a/apps/expo/features/weather/lib/weatherService.ts b/apps/expo/features/weather/lib/weatherService.ts index b15d209ac7..4ae0ef942f 100644 --- a/apps/expo/features/weather/lib/weatherService.ts +++ b/apps/expo/features/weather/lib/weatherService.ts @@ -49,12 +49,11 @@ export async function searchLocationsByCoordinates( /** * Get detailed weather data for a location */ -export async function getWeatherData(latitude: number, longitude: number) { +export async function getWeatherData(id: number) { try { const response = await axiosInstance.get(`/api/weather/forecast`, { params: { - lat: latitude.toFixed(6), - lon: longitude.toFixed(6), + id, }, }); @@ -129,7 +128,7 @@ export function formatWeatherData(data: WeatherApiForecastResponse) { // Create the formatted weather data object return { - id: `${location.lat}_${location.lon}`, + id: location.id, name: location.name, temperature: Math.round(current.temp_f), condition: current.condition.text, diff --git a/apps/expo/features/weather/screens/LocationDetailScreen.tsx b/apps/expo/features/weather/screens/LocationDetailScreen.tsx index 4a046cbc39..3856fac534 100644 --- a/apps/expo/features/weather/screens/LocationDetailScreen.tsx +++ b/apps/expo/features/weather/screens/LocationDetailScreen.tsx @@ -28,9 +28,10 @@ export default function LocationDetailScreen() { const { showActionSheetWithOptions } = useActionSheet(); const { removeLocation } = useLocations(); + const locationId = parseInt(String(id), 10); // Get the locations array safely const locations = locationsState.state === 'hasData' ? locationsState.data : []; - const location = locations.find((loc) => loc.id === id); + const location = locations.find((loc) => loc.id === locationId); // Refresh weather data for this location const handleRefresh = async () => { diff --git a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx index 140d5a3550..21dc49ca28 100644 --- a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx +++ b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx @@ -39,8 +39,9 @@ export default function LocationPreviewScreen() { ]); // Extract location data from params - const latitude = Number.parseFloat(params.lat as string); - const longitude = Number.parseFloat(params.lon as string); + const _latitude = Number.parseFloat(params.lat as string); + const _longitude = Number.parseFloat(params.lon as string); + const locationId = Number.parseInt(String(params.id), 10); // const locationName = params.name as string; // const region = params.region as string; // const country = params.country as string; @@ -50,7 +51,7 @@ export default function LocationPreviewScreen() { setError(null); try { - const data = await getWeatherData(latitude, longitude); + const data = await getWeatherData(locationId); if (data) { const formattedData = formatWeatherData(data); setWeatherData(formattedData); diff --git a/apps/expo/features/weather/screens/LocationSearchScreen.tsx b/apps/expo/features/weather/screens/LocationSearchScreen.tsx index 21da998a00..0335644858 100644 --- a/apps/expo/features/weather/screens/LocationSearchScreen.tsx +++ b/apps/expo/features/weather/screens/LocationSearchScreen.tsx @@ -34,7 +34,7 @@ export default function LocationSearchScreen() { const searchInputRef = useRef(null); const [recentSearches, setRecentSearches] = useState([]); const [isAdding, setIsAdding] = useState(false); - const [addingLocationId, setAddingLocationId] = useState(null); + const [addingLocationId, setAddingLocationId] = useState(null); const [isGettingLocation, setIsGettingLocation] = useState(false); const [locationPermissionDenied, setLocationPermissionDenied] = useState(false); @@ -424,7 +424,7 @@ export default function LocationSearchScreen() { item.id} + keyExtractor={(item) => item.id.toString()} ListEmptyComponent={renderEmptyList} contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled" diff --git a/apps/expo/features/weather/screens/LocationsScreen.tsx b/apps/expo/features/weather/screens/LocationsScreen.tsx index bcdfcbf437..f4d6894424 100644 --- a/apps/expo/features/weather/screens/LocationsScreen.tsx +++ b/apps/expo/features/weather/screens/LocationsScreen.tsx @@ -80,11 +80,11 @@ function LocationsScreen() { }; }, [navigation, clearSearch, setSearchQuery]); - const handleLocationPress = (locationId: string) => { + const handleLocationPress = (locationId: number) => { router.push(`/weather/${locationId}`); }; - const handleSetActive = (locationId: string) => { + const handleSetActive = (locationId: number) => { setActiveLocation(locationId); // Show confirmation @@ -101,7 +101,7 @@ function LocationsScreen() { } }; - const handleRemoveLocation = (locationId: string) => { + const handleRemoveLocation = (locationId: number) => { removeLocation(locationId); }; diff --git a/apps/expo/features/weather/types.ts b/apps/expo/features/weather/types.ts index fe536f363e..92356e3c5a 100644 --- a/apps/expo/features/weather/types.ts +++ b/apps/expo/features/weather/types.ts @@ -12,6 +12,7 @@ export interface WeatherApiForecastResponse { } export interface Location { + id: number; name: string; region: string; country: string; @@ -173,7 +174,7 @@ export interface Alert { // Location shape used in app export interface WeatherLocation { - id: string; + id: number; name: string; temperature: number; condition: string; @@ -210,7 +211,7 @@ export interface WeatherLocation { } export type LocationSearchResult = { - id: string; + id: number; name: string; region: string; country: string; diff --git a/apps/expo/lib/api/client.ts b/apps/expo/lib/api/client.ts index 3b0c47cf24..db06dbf951 100644 --- a/apps/expo/lib/api/client.ts +++ b/apps/expo/lib/api/client.ts @@ -8,7 +8,7 @@ import axios, { import { store } from 'expo-app/atoms/store'; import { clientEnvs } from 'expo-app/env/clientEnvs'; import { refreshTokenAtom, tokenAtom } from 'expo-app/features/auth/atoms/authAtoms'; -import * as SecureStore from 'expo-secure-store'; +import Storage from 'expo-sqlite/kv-store'; // Define base API URL based on environment export const API_URL = clientEnvs.EXPO_PUBLIC_API_URL; @@ -50,7 +50,7 @@ const processQueue = (error: Error | null, token: string | null = null) => { axiosInstance.interceptors.request.use( async (config: InternalAxiosRequestConfig): Promise => { try { - const token = await SecureStore.getItemAsync('access_token'); + const token = await Storage.getItem('access_token'); // If token exists, attach it to the request if (token && config.headers) { @@ -91,7 +91,7 @@ axiosInstance.interceptors.response.use( try { // Get refresh token // const refreshToken = await store.get(refreshTokenAtom); - const refreshToken = await SecureStore.getItemAsync('refresh_token'); + const refreshToken = await Storage.getItem('refresh_token'); if (!refreshToken) { // No refresh token, logout user @@ -129,8 +129,9 @@ axiosInstance.interceptors.response.use( } catch (refreshError) { // Refresh failed, logout user // Clear tokens - await SecureStore.deleteItemAsync('access_token'); - await SecureStore.deleteItemAsync('refresh_token'); + await Storage.removeItem('access_token'); + await Storage.removeItem('refresh_token'); + await store.set(tokenAtom, null); await store.set(refreshTokenAtom, null); // Dispatch logout action diff --git a/apps/expo/lib/utils/buildPackItemImageUrl.ts b/apps/expo/lib/utils/buildPackItemImageUrl.ts new file mode 100644 index 0000000000..0ce0a7effe --- /dev/null +++ b/apps/expo/lib/utils/buildPackItemImageUrl.ts @@ -0,0 +1,7 @@ +import { clientEnvs } from 'expo-app/env/clientEnvs'; +import type { PackItem } from 'expo-app/features/packs'; + +export function buildPackItemImageUrl(item: PackItem): string { + const baseUrl = clientEnvs.EXPO_PUBLIC_R2_PUBLIC_URL; + return `${baseUrl}/${item.userId}-${item.image}`; +} diff --git a/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts b/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts new file mode 100644 index 0000000000..1f5c23f69f --- /dev/null +++ b/apps/expo/lib/utils/buildPackTemplateItemImageUrl.ts @@ -0,0 +1,14 @@ +import { clientEnvs } from 'expo-app/env/clientEnvs'; + +export function buildPackTemplateItemImageUrl(image?: string | null): string | null { + if (!image) return null; + + // If image is already a full URL, return it + if (image.startsWith('http')) { + return image; + } + + // Otherwise, build URL using R2 base URL + const baseUrl = clientEnvs.EXPO_PUBLIC_R2_PUBLIC_URL; + return `${baseUrl}/${image}`; +} diff --git a/apps/expo/lib/utils/itemCalculations.ts b/apps/expo/lib/utils/itemCalculations.ts index d256a634ee..6e0f12dea8 100644 --- a/apps/expo/lib/utils/itemCalculations.ts +++ b/apps/expo/lib/utils/itemCalculations.ts @@ -16,7 +16,7 @@ export function isCatalogItem(item: Item): item is CatalogItem { */ export function getEffectiveWeight(item: Item): number { if (isCatalogItem(item)) { - return item.defaultWeight ?? 0; + return item.weight ?? 0; } return item.weight; } @@ -32,7 +32,19 @@ export function getQuantity(item: Item): number { * Gets the weight unit of an item */ export function getWeightUnit(item: Item): WeightUnit { - return isCatalogItem(item) ? (item.defaultWeightUnit ?? 'g') : item.weightUnit; + if (isCatalogItem(item)) { + // CatalogItem weightUnit is a string, need to ensure it's a valid WeightUnit + const unit = item.weightUnit ?? 'g'; + if (isWeightUnit(unit)) { + return unit; + } + return 'g'; // default fallback + } + return item.weightUnit; +} + +function isWeightUnit(value: string): value is WeightUnit { + return ['g', 'oz', 'kg', 'lb'].includes(value); } /** Gets the notes of an item */ diff --git a/apps/expo/package.json b/apps/expo/package.json index a8a24e05ef..2ce7b33be7 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -1,6 +1,6 @@ { - "name": "packrat-expo-v2-poc", - "version": "2.0.4", + "name": "packrat-expo-app", + "version": "2.0.5", "main": "expo-router/entry", "scripts": { "android": "expo run:android", @@ -66,7 +66,7 @@ "@shopify/flash-list": "1.7.6", "@stardazed/streams-text-encoding": "^1.0.2", "@tanstack/react-form": "^1.0.5", - "@tanstack/react-query": "^5.67.3", + "@tanstack/react-query": "^5.70.0", "ai": "^5.0.11", "axios": "^1.8.4", "class-variance-authority": "^0.7.1", @@ -78,7 +78,7 @@ "expo-dev-client": "~5.2.4", "expo-file-system": "~18.1.11", "expo-haptics": "~14.1.4", - "expo-image": "~2.3.2", + "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", @@ -92,12 +92,12 @@ "expo-system-ui": "~5.0.10", "expo-updates": "~0.28.17", "expo-web-browser": "~14.2.0", - "google-auth-library": "^9.15.1", + "google-auth-library": "^10.1.0", "i": "^0.3.7", "jotai": "^2.12.2", "lodash.debounce": "^4.0.8", "nativewind": "^4.1.23", - "radash": "^12.1.0", + "radash": "^12.1.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", @@ -111,7 +111,7 @@ "react-native-screens": "~4.11.1", "react-native-uitextview": "^1.1.4", "react-native-web": "^0.20.0", - "tailwind-merge": "^2.2.1", + "tailwind-merge": "^2.5.5", "use-debounce": "^10.0.5", "zod": "^3.24.2" }, @@ -129,8 +129,8 @@ "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.11", "rimraf": "^6.0.1", - "tailwindcss": "^3.4.0", - "typescript": "~5.8.3" + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2" }, "eslintConfig": { "extends": "universe/native", diff --git a/apps/guides/mdx-components.tsx b/apps/guides/mdx-components.tsx index af3a2f5890..99f8743664 100644 --- a/apps/guides/mdx-components.tsx +++ b/apps/guides/mdx-components.tsx @@ -1,7 +1,10 @@ -import type { MDXComponents } from 'mdx/types'; import Image, { type ImageProps } from 'next/image'; import Link, { type LinkProps } from 'next/link'; +type MDXComponents = { + [key: string]: React.ComponentType>; +}; + export function useMDXComponents(components: MDXComponents): MDXComponents { return { // Use custom components diff --git a/apps/guides/package.json b/apps/guides/package.json index ae817aba05..f649a2c48a 100644 --- a/apps/guides/package.json +++ b/apps/guides/package.json @@ -1,6 +1,6 @@ { - "name": "packrat-guides", - "version": "2.0.4", + "name": "packrat-guides-app", + "version": "2.0.5", "private": true, "scripts": { "dev": "next dev", @@ -11,7 +11,7 @@ "clean": "bunx rimraf .next node_modules out .vercel" }, "dependencies": { - "@ai-sdk/openai": "^1.3.7", + "@ai-sdk/openai": "^2.0.11", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -43,7 +43,7 @@ "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.70.0", "@tanstack/react-query-devtools": "^5.70.0", - "ai": "^4.3.2", + "ai": "^5.0.11", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -58,9 +58,9 @@ "next": "^15.3.4", "next-themes": "^0.4.6", "path": "^0.12.7", - "react": "^19", + "react": "19.0.0", "react-day-picker": "8.10.1", - "react-dom": "^19", + "react-dom": "19.0.0", "react-hook-form": "^7.58.1", "react-resizable-panels": "^2.1.9", "recharts": "2.15.0", @@ -74,8 +74,9 @@ "zod": "^3.24.2" }, "devDependencies": { + "@types/mdx": "^2.0.13", "@types/node": "^22.15.33", - "@types/react": "^19", + "@types/react": "~19.0.10", "@types/react-dom": "^19.1.6", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", diff --git a/apps/landing/components/sections/feature-section.tsx b/apps/landing/components/sections/feature-section.tsx index f93e0f9c34..784a9c3aa9 100644 --- a/apps/landing/components/sections/feature-section.tsx +++ b/apps/landing/components/sections/feature-section.tsx @@ -1,10 +1,12 @@ +import { Button } from 'landing-app/components/ui/button'; import DeviceMockup from 'landing-app/components/ui/device-mockup'; import FeatureCard from 'landing-app/components/ui/feature-card'; import GradientBackground from 'landing-app/components/ui/gradient-background'; import GradientBorderCard from 'landing-app/components/ui/gradient-border-card'; import GradientText from 'landing-app/components/ui/gradient-text'; import { siteConfig } from 'landing-app/config/site'; -import { Check } from 'lucide-react'; +import { Check, ChevronRight } from 'lucide-react'; +import Link from 'next/link'; export default function FeatureSection() { return ( @@ -153,9 +155,69 @@ export default function FeatureSection() { + {/* Feature showcase 3 */} + +
+
+
+
+ + Open Book icon + + +
+

+ {siteConfig.features[2].title} +

+

+ {siteConfig.features[2].description} +

+
    +
  • + + Destination highlights and recommendations +
  • +
  • + + Safety and survival tips +
  • +
  • + + Expert gear recommendations +
  • +
+
+ +
+
+
+
+ +
+
+
+ {/* Other features grid */}
- {siteConfig.features.slice(2).map((feature) => ( + {siteConfig.features.slice(3).map((feature) => ( \ No newline at end of file diff --git a/apps/landing/public/placeholder-user.jpg b/apps/landing/public/placeholder-user.jpg deleted file mode 100644 index 6faa819ce7..0000000000 Binary files a/apps/landing/public/placeholder-user.jpg and /dev/null differ diff --git a/apps/landing/public/placeholder.jpg b/apps/landing/public/placeholder.jpg deleted file mode 100644 index a6bf2ee648..0000000000 Binary files a/apps/landing/public/placeholder.jpg and /dev/null differ diff --git a/bun.lock b/bun.lock index 37b8c2682a..4082877d90 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, }, "apps/expo": { - "name": "packrat-expo-v2-poc", + "name": "packrat-expo-app", "version": "2.0.3", "dependencies": { "@ai-sdk/react": "^2.0.11", @@ -47,7 +47,7 @@ "@shopify/flash-list": "1.7.6", "@stardazed/streams-text-encoding": "^1.0.2", "@tanstack/react-form": "^1.0.5", - "@tanstack/react-query": "^5.67.3", + "@tanstack/react-query": "^5.70.0", "ai": "^5.0.11", "axios": "^1.8.4", "class-variance-authority": "^0.7.1", @@ -59,7 +59,7 @@ "expo-dev-client": "~5.2.4", "expo-file-system": "~18.1.11", "expo-haptics": "~14.1.4", - "expo-image": "~2.3.2", + "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", @@ -73,12 +73,12 @@ "expo-system-ui": "~5.0.10", "expo-updates": "~0.28.17", "expo-web-browser": "~14.2.0", - "google-auth-library": "^9.15.1", + "google-auth-library": "^10.1.0", "i": "^0.3.7", "jotai": "^2.12.2", "lodash.debounce": "^4.0.8", "nativewind": "^4.1.23", - "radash": "^12.1.0", + "radash": "^12.1.1", "react": "19.0.0", "react-dom": "19.0.0", "react-native": "0.79.5", @@ -92,7 +92,7 @@ "react-native-screens": "~4.11.1", "react-native-uitextview": "^1.1.4", "react-native-web": "^0.20.0", - "tailwind-merge": "^2.2.1", + "tailwind-merge": "^2.5.5", "use-debounce": "^10.0.5", "zod": "^3.24.2", }, @@ -110,15 +110,15 @@ "prettier": "^3.2.5", "prettier-plugin-tailwindcss": "^0.5.11", "rimraf": "^6.0.1", - "tailwindcss": "^3.4.0", - "typescript": "~5.8.3", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.2", }, }, "apps/guides": { - "name": "packrat-guides", + "name": "packrat-guides-app", "version": "2.0.3", "dependencies": { - "@ai-sdk/openai": "^1.3.7", + "@ai-sdk/openai": "^2.0.11", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", @@ -150,7 +150,7 @@ "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.70.0", "@tanstack/react-query-devtools": "^5.70.0", - "ai": "^4.3.2", + "ai": "^5.0.11", "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -165,9 +165,9 @@ "next": "^15.3.4", "next-themes": "^0.4.6", "path": "^0.12.7", - "react": "^19", + "react": "19.0.0", "react-day-picker": "8.10.1", - "react-dom": "^19", + "react-dom": "19.0.0", "react-hook-form": "^7.58.1", "react-resizable-panels": "^2.1.9", "recharts": "2.15.0", @@ -181,8 +181,9 @@ "zod": "^3.24.2", }, "devDependencies": { + "@types/mdx": "^2.0.13", "@types/node": "^22.15.33", - "@types/react": "^19", + "@types/react": "~19.0.10", "@types/react-dom": "^19.1.6", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", @@ -190,7 +191,7 @@ }, }, "apps/landing": { - "name": "packrat-landing", + "name": "packrat-landing-app", "version": "2.0.3", "dependencies": { "@emotion/is-prop-valid": "^1.3.1", @@ -233,9 +234,9 @@ "lucide-react": "^0.454.0", "next": "^15.3.4", "next-themes": "^0.4.6", - "react": "^19", + "react": "19.0.0", "react-day-picker": "8.10.1", - "react-dom": "^19", + "react-dom": "19.0.0", "react-hook-form": "^7.58.1", "react-resizable-panels": "^2.1.9", "recharts": "2.15.0", @@ -247,7 +248,7 @@ }, "devDependencies": { "@types/node": "^22.15.33", - "@types/react": "^19", + "@types/react": "~19.0.10", "@types/react-dom": "^19.1.6", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", @@ -264,24 +265,28 @@ "@hono/sentry": "^1.2.2", "@hono/zod-openapi": "^0.19.2", "@hono/zod-validator": "^0.4.3", + "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", "@scalar/hono-api-reference": "^0.8.0", "@types/nodemailer": "^6.4.17", - "ai": "^5.0.10", + "ai": "^5.0.11", "bcryptjs": "^3.0.2", "csv-parse": "^5.6.0", "drizzle-kit": "^0.30.6", "drizzle-orm": "^0.44.4", + "drizzle-zod": "^0.8.3", "google-auth-library": "^10.1.0", "gray-matter": "^4.0.3", "hono": "^4.7.5", "hono-openapi": "^0.4.6", + "linkedom": "^0.18.11", "nodemailer": "^6.10.0", "pg": "^8.16.3", + "radash": "^12.1.1", "resend": "^4.2.0", "workers-ai-provider": "^0.7.2", "ws": "^8.18.1", - "zod": "^3.25.76", + "zod": "^3.24.2", "zod-openapi": "^4.2.4", }, "devDependencies": { @@ -297,7 +302,7 @@ }, "packages/ui": { "name": "@packrat/ui", - "version": "2.0.3", + "version": "2.0.4", "dependencies": { "@packrat-ai/nativewindui": "1.0.8", }, @@ -318,8 +323,6 @@ "@ai-sdk/react": ["@ai-sdk/react@2.0.11", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.2", "ai": "5.0.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.25.76 || ^4" }, "optionalPeers": ["zod"] }, "sha512-XL73e7RSOQjYRCJQ96sDY6TxrMJK9YBgI518E6Jy306BjRwy5XyY94e/DN71TE6VpiwDzxixlymfDK90Ro95Jg=="], - "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -874,6 +877,8 @@ "@manypkg/tools": ["@manypkg/tools@2.1.0", "", { "dependencies": { "jju": "^1.4.0", "js-yaml": "^4.1.0", "tinyglobby": "^0.2.13" } }, "sha512-0FOIepYR4ugPYaHwK7hDeHDkfPOBVvayt9QpvRbi2LT/h2b0GaE/gM9Gag7fsnyYyNaTZ2IGyOuVg07IYepvYQ=="], + "@mozilla/readability": ["@mozilla/readability@0.6.0", "", {}, "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ=="], + "@neondatabase/serverless": ["@neondatabase/serverless@1.0.1", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-O6yC5TT0jbw86VZVkmnzCZJB0hfxBl0JJz6f+3KHoZabjb/X08r9eFA+vuY06z1/qaovykvdkrXYq3SPUuvogA=="], "@next/env": ["@next/env@15.4.6", "", {}, "sha512-yHDKVTcHrZy/8TWhj0B23ylKv5ypocuCwey9ZqPyv4rPdUdRzpGCkSi03t04KBPyU96kxVtUqx6O3nE1kpxASQ=="], @@ -1368,8 +1373,6 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/fs-extra": ["@types/fs-extra@11.0.4", "", { "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ=="], @@ -1402,6 +1405,8 @@ "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/node": ["@types/node@22.17.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA=="], @@ -1588,6 +1593,8 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "bowser": ["bowser@2.12.0", "", {}, "sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg=="], "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], @@ -1724,10 +1731,16 @@ "css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "cssom": ["cssom@0.5.0", "", {}, "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csv-parse": ["csv-parse@5.6.0", "", {}, "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q=="], @@ -1814,8 +1827,6 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], - "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], - "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -1840,6 +1851,8 @@ "drizzle-orm": ["drizzle-orm@0.44.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-ZyzKFpTC/Ut3fIqc2c0dPZ6nhchQXriTsqTNs4ayRgl6sZcFlMs9QZKPSHXK4bdOf41GHGWf+FrpcDDYwW+W6Q=="], + "drizzle-zod": ["drizzle-zod@0.8.3", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1860,7 +1873,7 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "env-editor": ["env-editor@0.4.2", "", {}, "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA=="], @@ -1982,7 +1995,7 @@ "expo-haptics": ["expo-haptics@14.1.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-QZdE3NMX74rTuIl82I+n12XGwpDWKb8zfs5EpwsnGi/D/n7O2Jd4tO5ivH+muEG/OCJOMq5aeaVDqqaQOhTkcA=="], - "expo-image": ["expo-image@2.3.2", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TOp7UR1mzeCxzs3c/6MV2Wy7jBfJpKq8aVC06gkLfxHsCVMeGqCXc+6GMrGIVrjG938LEub4dwnrE0OuSE2Qwg=="], + "expo-image": ["expo-image@2.4.0", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw=="], "expo-image-loader": ["expo-image-loader@5.1.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q=="], @@ -2198,11 +2211,13 @@ "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], @@ -2296,8 +2311,6 @@ "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], @@ -2376,8 +2389,6 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="], - "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], @@ -2452,6 +2463,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkedom": ["linkedom@0.18.11", "", { "dependencies": { "css-select": "^5.1.0", "cssom": "^0.5.0", "html-escaper": "^3.0.3", "htmlparser2": "^10.0.0", "uhyphen": "^0.2.0" } }, "sha512-K03GU3FUlnhBAP0jPb7tN7YJl7LbjZx30Z8h6wgLXusnKF7+BEZvfEbdkN/lO9LfFzxN3S0ZAriDuJ/13dIsLA=="], + "linkify-it": ["linkify-it@4.0.1", "", { "dependencies": { "uc.micro": "^1.0.1" } }, "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw=="], "load-json-file": ["load-json-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^2.2.0", "pify": "^2.0.0", "pinkie-promise": "^2.0.0", "strip-bom": "^2.0.0" } }, "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A=="], @@ -2656,6 +2669,8 @@ "npm-package-arg": ["npm-package-arg@11.0.3", "", { "dependencies": { "hosted-git-info": "^7.0.0", "proc-log": "^4.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^5.0.0" } }, "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw=="], + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], "ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="], @@ -2710,11 +2725,11 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "packrat-expo-v2-poc": ["packrat-expo-v2-poc@workspace:apps/expo"], + "packrat-expo-app": ["packrat-expo-app@workspace:apps/expo"], - "packrat-guides": ["packrat-guides@workspace:apps/guides"], + "packrat-guides-app": ["packrat-guides-app@workspace:apps/guides"], - "packrat-landing": ["packrat-landing@workspace:apps/landing"], + "packrat-landing-app": ["packrat-landing-app@workspace:apps/landing"], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -3256,6 +3271,8 @@ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], "undici": ["undici@7.13.0", "", {}, "sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA=="], @@ -3418,10 +3435,6 @@ "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-/iP1sKc6UdJgGH98OCly7sWJKv+J9G47PnTjIj40IJMUQKwDrUMyf7zOOfRtPwSuNifYhSoJQ4s1WltI65gJ/g=="], - "@ai-sdk/ui-utils/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], - - "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], - "@apidevtools/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -3502,6 +3515,8 @@ "@manypkg/tools/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "@packrat-ai/nativewindui/expo-image": ["expo-image@2.3.2", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-TOp7UR1mzeCxzs3c/6MV2Wy7jBfJpKq8aVC06gkLfxHsCVMeGqCXc+6GMrGIVrjG938LEub4dwnrE0OuSE2Qwg=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -3646,7 +3661,7 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -3658,8 +3673,6 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "jsondiffpatch/chalk": ["chalk@5.5.0", "", {}, "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg=="], - "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "lightningcss/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], @@ -3674,6 +3687,8 @@ "markdown-it/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "markdown-it/entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="], + "meow/object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="], @@ -3708,12 +3723,6 @@ "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - "packrat-expo-v2-poc/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], - - "packrat-guides/@ai-sdk/openai": ["@ai-sdk/openai@1.3.24", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q=="], - - "packrat-guides/ai": ["ai@4.3.19", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q=="], - "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "prop-types/object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -4012,6 +4021,8 @@ "flat-cache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "html-to-text/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "log-symbols/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], @@ -4072,22 +4083,6 @@ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "packrat-expo-v2-poc/google-auth-library/gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="], - - "packrat-expo-v2-poc/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="], - - "packrat-expo-v2-poc/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="], - - "packrat-guides/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], - - "packrat-guides/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], - - "packrat-guides/ai/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], - - "packrat-guides/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], - - "packrat-guides/ai/@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="], - "react-native/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "read-pkg-up/find-up/path-exists": ["path-exists@2.1.0", "", { "dependencies": { "pinkie-promise": "^2.0.0" } }, "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ=="], @@ -4162,10 +4157,6 @@ "ora/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], - "packrat-expo-v2-poc/google-auth-library/gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - - "packrat-expo-v2-poc/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], - "react-native/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/package.json b/package.json index c88abee1ba..06f35ca641 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "packrat-monorepo", - "version": "2.0.4", + "version": "2.0.5", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/api/docker-compose.test.yml b/packages/api/docker-compose.test.yml index 4a0daa992d..af074c3262 100644 --- a/packages/api/docker-compose.test.yml +++ b/packages/api/docker-compose.test.yml @@ -1,6 +1,6 @@ services: postgres-test: - image: postgres:15-alpine + image: pgvector/pgvector:pg15 environment: POSTGRES_DB: packrat_test POSTGRES_USER: test_user diff --git a/packages/api/drizzle/0028_left_stellaris.sql b/packages/api/drizzle/0028_left_stellaris.sql new file mode 100644 index 0000000000..769212c1e5 --- /dev/null +++ b/packages/api/drizzle/0028_left_stellaris.sql @@ -0,0 +1 @@ +ALTER TABLE "etl_jobs" RENAME COLUMN "total_processed" TO "total_count"; \ No newline at end of file diff --git a/packages/api/drizzle/meta/0028_snapshot.json b/packages/api/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000000..6b4bd4e015 --- /dev/null +++ b/packages/api/drizzle/meta/0028_snapshot.json @@ -0,0 +1,1439 @@ +{ + "id": "e310bd2c-1ddd-41b9-8eab-241ec4b7b0f1", + "prevId": "886d437c-9833-4985-9aef-cdcd0a57febd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_providers": { + "name": "auth_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_providers_user_id_users_id_fk": { + "name": "auth_providers_user_id_users_id_fk", + "tableFrom": "auth_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_item_etl_jobs": { + "name": "catalog_item_etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etl_job_id": { + "name": "etl_job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk": { + "name": "catalog_item_etl_jobs_catalog_item_id_catalog_items_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk": { + "name": "catalog_item_etl_jobs_etl_job_id_etl_jobs_id_fk", + "tableFrom": "catalog_item_etl_jobs", + "tableTo": "etl_jobs", + "columnsFrom": ["etl_job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_items": { + "name": "catalog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_url": { + "name": "product_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rating_value": { + "name": "rating_value", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "availability": { + "name": "availability", + "type": "availability", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "seller": { + "name": "seller", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "material": { + "name": "material", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variants": { + "name": "variants", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "techs": { + "name": "techs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "qas": { + "name": "qas", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "faqs": { + "name": "faqs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_idx": { + "name": "embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "catalog_items_sku_unique": { + "name": "catalog_items_sku_unique", + "nullsNotDistinct": false, + "columns": ["sku"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.etl_jobs": { + "name": "etl_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "etl_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_valid": { + "name": "total_valid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_invalid": { + "name": "total_invalid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scraper_revision": { + "name": "scraper_revision", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "etl_jobs_scraper_revision_idx": { + "name": "etl_jobs_scraper_revision_idx", + "columns": [ + { + "expression": "scraper_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invalid_item_logs": { + "name": "invalid_item_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "row_index": { + "name": "row_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invalid_item_logs_job_id_etl_jobs_id_fk": { + "name": "invalid_item_logs_job_id_etl_jobs_id_fk", + "tableFrom": "invalid_item_logs", + "tableTo": "etl_jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.one_time_passwords": { + "name": "one_time_passwords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(6)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "one_time_passwords_user_id_users_id_fk": { + "name": "one_time_passwords_user_id_users_id_fk", + "tableFrom": "one_time_passwords", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_items": { + "name": "pack_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "template_item_id": { + "name": "template_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pack_items_embedding_idx": { + "name": "pack_items_embedding_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "pack_items_pack_id_packs_id_fk": { + "name": "pack_items_pack_id_packs_id_fk", + "tableFrom": "pack_items", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_user_id_users_id_fk": { + "name": "pack_items_user_id_users_id_fk", + "tableFrom": "pack_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_items_template_item_id_pack_template_items_id_fk": { + "name": "pack_items_template_item_id_pack_template_items_id_fk", + "tableFrom": "pack_items", + "tableTo": "pack_template_items", + "columnsFrom": ["template_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_template_items": { + "name": "pack_template_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumable": { + "name": "consumable", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "worn": { + "name": "worn", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pack_template_id": { + "name": "pack_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_item_id": { + "name": "catalog_item_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_template_items_pack_template_id_pack_templates_id_fk": { + "name": "pack_template_items_pack_template_id_pack_templates_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "pack_templates", + "columnsFrom": ["pack_template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pack_template_items_catalog_item_id_catalog_items_id_fk": { + "name": "pack_template_items_catalog_item_id_catalog_items_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "catalog_items", + "columnsFrom": ["catalog_item_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "pack_template_items_user_id_users_id_fk": { + "name": "pack_template_items_user_id_users_id_fk", + "tableFrom": "pack_template_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_templates": { + "name": "pack_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_app_template": { + "name": "is_app_template", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_templates_user_id_users_id_fk": { + "name": "pack_templates_user_id_users_id_fk", + "tableFrom": "pack_templates", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weight_history": { + "name": "weight_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "weight_history_user_id_users_id_fk": { + "name": "weight_history_user_id_users_id_fk", + "tableFrom": "weight_history", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weight_history_pack_id_packs_id_fk": { + "name": "weight_history_pack_id_packs_id_fk", + "tableFrom": "weight_history", + "tableTo": "packs", + "columnsFrom": ["pack_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.packs": { + "name": "packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "local_created_at": { + "name": "local_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "local_updated_at": { + "name": "local_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "packs_user_id_users_id_fk": { + "name": "packs_user_id_users_id_fk", + "tableFrom": "packs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "packs_template_id_pack_templates_id_fk": { + "name": "packs_template_id_pack_templates_id_fk", + "tableFrom": "packs", + "tableTo": "pack_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token": { + "name": "replaced_by_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reported_content": { + "name": "reported_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_response": { + "name": "ai_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_comment": { + "name": "user_comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed": { + "name": "reviewed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reported_content_user_id_users_id_fk": { + "name": "reported_content_user_id_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reported_content_reviewed_by_users_id_fk": { + "name": "reported_content_reviewed_by_users_id_fk", + "tableFrom": "reported_content", + "tableTo": "users", + "columnsFrom": ["reviewed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'USER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 18980b9f17..d8ff82d66c 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1755197646956, "tag": "0027_past_madrox", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1756039667476, + "tag": "0028_left_stellaris", + "breakpoints": true } ] } diff --git a/packages/api/migrate.ts b/packages/api/migrate.ts index ed64b4dc11..377488a760 100644 --- a/packages/api/migrate.ts +++ b/packages/api/migrate.ts @@ -1,22 +1,61 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { neon, neonConfig } from '@neondatabase/serverless'; import { drizzle } from 'drizzle-orm/neon-http'; import { migrate } from 'drizzle-orm/neon-http/migrator'; +import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; +import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'; +import { Client } from 'pg'; import * as ws from 'ws'; // Required for Neon serverless driver to work in Node.js neonConfig.webSocketConstructor = ws; +// Get the directory where this script is located +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Check if we're using a standard PostgreSQL URL (for tests) vs Neon URL +// Import the utility function from src/db/index.ts since it's defined there +const isStandardPostgresUrl = (url: string) => { + // Parse and check the hostname to robustly exclude Neon domains + try { + const u = new URL(url); + // Only allow if NOT neon.tech and NOT neon.com, and NOT their subdomains + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + return u.protocol === 'postgres:' && !isNeonTech && !isNeonCom; + } catch { + // Any parsing error: treat as NOT standard Postgres + return false; + } +}; + async function runMigrations() { if (!process.env.NEON_DATABASE_URL) { throw new Error('NEON_DATABASE_URL is not set'); } - const sql = neon(process.env.NEON_DATABASE_URL); - const db = drizzle(sql); - console.log('Running migrations...'); - await migrate(db, { migrationsFolder: `${__dirname}/drizzle` }); + const url = process.env.NEON_DATABASE_URL; + + if (isStandardPostgresUrl(url)) { + // Use node-postgres for standard PostgreSQL + console.log('Using PostgreSQL migrations...'); + const client = new Client({ connectionString: url }); + await client.connect(); + const db = drizzlePg(client); + await migratePg(db, { migrationsFolder: join(__dirname, 'drizzle') }); + await client.end(); + } else { + // Use Neon serverless for Neon URLs + console.log('Using Neon serverless migrations...'); + const sql = neon(url); + const db = drizzle(sql); + await migrate(db, { migrationsFolder: join(__dirname, 'drizzle') }); + } console.log('Migrations completed successfully!'); process.exit(0); diff --git a/packages/api/package.json b/packages/api/package.json index 8532f7d620..9cab39a95e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -2,8 +2,8 @@ "name": "@packrat/api", "scripts": { "dev": "wrangler dev -e=dev", - "deploy": "bun run validate:cloudflare-env && wrangler deploy --minify", - "deploy:dev": "bun run validate:cloudflare-env && wrangler deploy --minify -e=dev", + "deploy": "wrangler deploy --minify", + "deploy:dev": "wrangler deploy --minify -e=dev", "validate:cloudflare-env": "bun scripts/validate-cloudflare-api-env.ts", "check-types": "tsc --noEmit", "check-types-watch": "tsc --noEmit --watch", @@ -19,24 +19,28 @@ "@hono/sentry": "^1.2.2", "@hono/zod-openapi": "^0.19.2", "@hono/zod-validator": "^0.4.3", + "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", "@scalar/hono-api-reference": "^0.8.0", "@types/nodemailer": "^6.4.17", - "ai": "^5.0.10", + "ai": "^5.0.11", "bcryptjs": "^3.0.2", "csv-parse": "^5.6.0", "drizzle-kit": "^0.30.6", "drizzle-orm": "^0.44.4", + "drizzle-zod": "^0.8.3", "google-auth-library": "^10.1.0", "gray-matter": "^4.0.3", "hono": "^4.7.5", "hono-openapi": "^0.4.6", + "linkedom": "^0.18.11", "nodemailer": "^6.10.0", "pg": "^8.16.3", + "radash": "^12.1.1", "resend": "^4.2.0", "workers-ai-provider": "^0.7.2", "ws": "^8.18.1", - "zod": "^3.25.76", + "zod": "^3.24.2", "zod-openapi": "^4.2.4" }, "devDependencies": { diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index fad746b433..091602a652 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -1,26 +1,54 @@ import { neon } from '@neondatabase/serverless'; import * as schema from '@packrat/api/db/schema'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; import { drizzle } from 'drizzle-orm/neon-http'; +import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import type { Context } from 'hono'; +import { Pool } from 'pg'; -// Create SQL client with Neon for Hono contexts +// Check if we're using a standard PostgreSQL URL (for tests) vs Neon URL +const isStandardPostgresUrl = (url: string) => { + // Parse and check the hostname to robustly exclude Neon domains + try { + const u = new URL(url); + // Only allow if NOT neon.tech and NOT neon.com, and NOT their subdomains + const host = u.hostname.toLowerCase(); + const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); + const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + return u.protocol === 'postgres:' && !isNeonTech && !isNeonCom; + } catch { + // Any parsing error: treat as NOT standard Postgres + return false; + } +}; + +// Create database connection based on URL type +const createConnection = (url: string) => { + if (isStandardPostgresUrl(url)) { + // Use node-postgres for standard PostgreSQL (tests) + const pool = new Pool({ connectionString: url }); + return drizzlePg(pool, { schema }); + } else { + // Use Neon serverless for production + const sql = neon(url); + return drizzle(sql, { schema }); + } +}; + +// Create SQL client with appropriate driver for Hono contexts export const createDb = (c: Context) => { const { NEON_DATABASE_URL } = getEnv(c); - const sql = neon(NEON_DATABASE_URL); - return drizzle(sql, { schema }); + return createConnection(NEON_DATABASE_URL); }; -// Create a read-only SQL client with Neon for Hono contexts +// Create a read-only SQL client with appropriate driver for Hono contexts export const createReadOnlyDb = (c: Context) => { const { NEON_DATABASE_URL_READONLY } = getEnv(c); - const sql = neon(NEON_DATABASE_URL_READONLY); - return drizzle(sql, { schema }); + return createConnection(NEON_DATABASE_URL_READONLY); }; -// Create SQL client with Neon for queue workers +// Create SQL client with appropriate driver for queue workers export const createDbClient = (env: Env) => { - const sql = neon(env.NEON_DATABASE_URL); - return drizzle(sql, { schema }); + return createConnection(env.NEON_DATABASE_URL); }; diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 30a195b743..1da3b5469b 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -388,7 +388,7 @@ export const etlJobs = pgTable( objectKey: text('object_key').notNull(), startedAt: timestamp('started_at').notNull(), completedAt: timestamp('completed_at'), - totalProcessed: integer('total_processed'), + totalCount: integer('total_count'), totalValid: integer('total_valid'), totalInvalid: integer('total_invalid'), scraperRevision: text('scraper_revision').notNull(), // Git commit SHA or tag diff --git a/packages/api/src/db/zod-schemas.ts b/packages/api/src/db/zod-schemas.ts new file mode 100644 index 0000000000..d577e873b0 --- /dev/null +++ b/packages/api/src/db/zod-schemas.ts @@ -0,0 +1,57 @@ +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { + authProviders, + catalogItemEtlJobs, + catalogItems, + etlJobs, + invalidItemLogs, + oneTimePasswords, + packItems, + packs, + packTemplateItems, + packTemplates, + packWeightHistory, + refreshTokens, + reportedContent, + users, +} from './schema'; + +// User schemas +export const selectUserSchema = createSelectSchema(users); +export const insertUserSchema = createInsertSchema(users); + +// Auth schemas +export const selectAuthProviderSchema = createSelectSchema(authProviders); +export const insertAuthProviderSchema = createInsertSchema(authProviders); +export const selectRefreshTokenSchema = createSelectSchema(refreshTokens); +export const insertRefreshTokenSchema = createInsertSchema(refreshTokens); +export const selectOneTimePasswordSchema = createSelectSchema(oneTimePasswords); +export const insertOneTimePasswordSchema = createInsertSchema(oneTimePasswords); + +// Pack schemas +export const selectPackSchema = createSelectSchema(packs); +export const insertPackSchema = createInsertSchema(packs); +export const selectPackItemSchema = createSelectSchema(packItems); +export const insertPackItemSchema = createInsertSchema(packItems); +export const selectPackWeightHistorySchema = createSelectSchema(packWeightHistory); +export const insertPackWeightHistorySchema = createInsertSchema(packWeightHistory); + +// Catalog schemas +export const selectCatalogItemSchema = createSelectSchema(catalogItems); +export const insertCatalogItemSchema = createInsertSchema(catalogItems); + +// Pack template schemas +export const selectPackTemplateSchema = createSelectSchema(packTemplates); +export const insertPackTemplateSchema = createInsertSchema(packTemplates); +export const selectPackTemplateItemSchema = createSelectSchema(packTemplateItems); +export const insertPackTemplateItemSchema = createInsertSchema(packTemplateItems); + +// ETL and reporting schemas +export const selectReportedContentSchema = createSelectSchema(reportedContent); +export const insertReportedContentSchema = createInsertSchema(reportedContent); +export const selectInvalidItemLogSchema = createSelectSchema(invalidItemLogs); +export const insertInvalidItemLogSchema = createInsertSchema(invalidItemLogs); +export const selectEtlJobSchema = createSelectSchema(etlJobs); +export const insertEtlJobSchema = createInsertSchema(etlJobs); +export const selectCatalogItemEtlJobSchema = createSelectSchema(catalogItemEtlJobs); +export const insertCatalogItemEtlJobSchema = createInsertSchema(catalogItemEtlJobs); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 4a201deb6b..41b537d30b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -2,15 +2,16 @@ import type { MessageBatch } from '@cloudflare/workers-types'; import { sentry } from '@hono/sentry'; import { OpenAPIHono } from '@hono/zod-openapi'; import { routes } from '@packrat/api/routes'; -import { type BaseQueueMessage, processQueueBatch } from '@packrat/api/services/etl/queue'; -import type { Env } from '@packrat/api/utils/env-validation'; +import { processQueueBatch } from '@packrat/api/services/etl/queue'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; +import { configureOpenAPI } from '@packrat/api/utils/openapi'; import { Scalar } from '@scalar/hono-api-reference'; import { cors } from 'hono/cors'; import { HTTPException } from 'hono/http-exception'; import { logger } from 'hono/logger'; import { CatalogService } from './services'; -import { LogsQueueConsumer } from './services/LogsQueueConsumer'; +import type { CatalogETLMessage } from './services/etl/types'; import type { Variables } from './types/variables'; const app = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); @@ -50,12 +51,22 @@ app.use(cors()); // Mount routes app.route('/api', routes); -// OpenAPI documentation and UI -app.doc('/doc', { - openapi: '3.0.0', - info: { title: 'PackRat API', version: '1.0.0' }, -}); -app.get('/scalar', Scalar({ url: '/doc' })); +// Configure OpenAPI documentation +configureOpenAPI(app); + +// Scalar UI with enhanced configuration +app.get( + '/scalar', + Scalar({ + url: '/doc', + theme: 'purple', + pageTitle: 'PackRat API Documentation', + defaultHttpClient: { + targetKey: 'js', + clientKey: 'fetch', + }, + }), +); // Health check endpoint app.get('/', (c) => { @@ -64,18 +75,12 @@ app.get('/', (c) => { export default { fetch: app.fetch, - async queue(batch: MessageBatch, env: Env): Promise { + async queue(batch: MessageBatch, env: Env): Promise { if (batch.queue === 'packrat-etl-queue' || batch.queue === 'packrat-etl-queue-dev') { if (!env.ETL_QUEUE) { throw new Error('ETL_QUEUE is not configured'); } - await processQueueBatch({ batch, env }); - } else if (batch.queue === 'packrat-logs-queue' || batch.queue === 'packrat-logs-queue-dev') { - if (!env.LOGS_QUEUE) { - throw new Error('LOGS_QUEUE is not configured'); - } - const consumer = new LogsQueueConsumer(); - await consumer.handle(batch, env); + await processQueueBatch({ batch: batch as MessageBatch, env }); } else if ( batch.queue === 'packrat-embeddings-queue' || batch.queue === 'packrat-embeddings-queue-dev' diff --git a/packages/api/src/middleware/apiKeyAuth.ts b/packages/api/src/middleware/apiKeyAuth.ts index f2f06cdc6f..f21a531426 100644 --- a/packages/api/src/middleware/apiKeyAuth.ts +++ b/packages/api/src/middleware/apiKeyAuth.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from 'hono'; -import { isValidApiKey } from '../utils/api-middleware'; +import { isValidApiKey } from '../utils/auth'; export const apiKeyAuthMiddleware: MiddlewareHandler = async (c, next) => { if (isValidApiKey(c)) { diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index da24ef9c10..b77c59d033 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -1,4 +1,4 @@ -import { isValidApiKey } from '@packrat/api/utils/api-middleware'; +import { isValidApiKey } from '@packrat/api/utils/auth'; import { getEnv } from '@packrat/api/utils/env-validation'; import type { MiddlewareHandler } from 'hono'; import { verify } from 'hono/jwt'; diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 4c1e9bfc9d..05c6894d4d 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -1,12 +1,15 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { catalogItems, packs, users } from '@packrat/api/db/schema'; -import type { Env } from '@packrat/api/utils/env-validation'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { UserSearchQuerySchema } from '@packrat/api/schemas/users'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; import { assertAllDefined } from '@packrat/api/utils/typeAssertions'; import { and, count, desc, eq, ilike, or, sql } from 'drizzle-orm'; import { basicAuth } from 'hono/basic-auth'; import { html, raw } from 'hono/html'; +import { z } from 'zod'; const adminRoutes = new OpenAPIHono<{ Bindings: Env }>(); @@ -732,7 +735,38 @@ adminRoutes.get('/catalog-search', async (c) => { }); // Admin API endpoints for getting data -adminRoutes.get('/stats', async (c) => { +const getStatsRoute = createRoute({ + method: 'get', + path: '/stats', + tags: ['Admin'], + summary: 'Get admin dashboard statistics', + description: 'Get count statistics for users, packs, and catalog items (Admin only)', + responses: { + 200: { + description: 'Admin statistics retrieved successfully', + content: { + 'application/json': { + schema: z.object({ + users: z.number().int().min(0), + packs: z.number().int().min(0), + items: z.number().int().min(0), + }), + }, + }, + }, + + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +adminRoutes.openapi(getStatsRoute, async (c) => { const db = createDb(c); try { @@ -745,22 +779,66 @@ adminRoutes.get('/stats', async (c) => { assertAllDefined(userCount, packCount, itemCount); - return c.json({ - users: userCount.count, - packs: packCount.count, - items: itemCount.count, - }); + return c.json( + { + users: userCount?.count ?? 0, + packs: packCount?.count ?? 0, + items: itemCount?.count ?? 0, + }, + 200, + ); } catch (error) { console.error('Error fetching stats:', error); - return c.json({ error: 'Failed to fetch stats' }, 500); + return c.json({ error: 'Failed to fetch stats', code: 'STATS_ERROR' }, 500); } }); // Keep the existing API endpoints for backward compatibility -adminRoutes.get('/users-list', async (c) => { +const getUsersListRoute = createRoute({ + method: 'get', + path: '/users-list', + tags: ['Admin'], + summary: 'List all users', + description: 'Get a list of all users in the system (Admin only)', + request: { + query: UserSearchQuerySchema.pick({ limit: true, offset: true }), + }, + responses: { + 200: { + description: 'Users list retrieved successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.number(), + email: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + role: z.string().nullable(), + emailVerified: z.boolean().nullable(), + createdAt: z.string().nullable(), + }), + ), + }, + }, + }, + + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +adminRoutes.openapi(getUsersListRoute, async (c) => { const db = createDb(c); try { + const { limit = 100, offset = 0 } = c.req.query(); const usersList = await db .select({ id: users.id, @@ -773,19 +851,68 @@ adminRoutes.get('/users-list', async (c) => { }) .from(users) .orderBy(desc(users.createdAt)) - .limit(100); + .limit(Number(limit)) + .offset(Number(offset)); + + const formattedUsers = usersList.map((user) => ({ + ...user, + createdAt: user.createdAt?.toISOString() || null, + })); - return c.json(usersList); + return c.json(formattedUsers, 200); } catch (error) { console.error('Error fetching users:', error); - return c.json({ error: 'Failed to fetch users' }, 500); + return c.json({ error: 'Failed to fetch users', code: 'USERS_FETCH_ERROR' }, 500); } }); -adminRoutes.get('/packs-list', async (c) => { +const getPacksListRoute = createRoute({ + method: 'get', + path: '/packs-list', + tags: ['Admin'], + summary: 'List all packs', + description: 'Get a list of all packs in the system (Admin only)', + request: { + query: z.object({ + limit: z.number().int().positive().max(100).default(100).optional(), + offset: z.number().int().min(0).default(0).optional(), + }), + }, + responses: { + 200: { + description: 'Packs list retrieved successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + category: z.string(), + isPublic: z.boolean().nullable(), + createdAt: z.string().nullable(), + userEmail: z.string().nullable(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +adminRoutes.openapi(getPacksListRoute, async (c) => { const db = createDb(c); try { + const { limit = 100, offset = 0 } = c.req.query(); const packsList = await db .select({ id: packs.id, @@ -800,19 +927,69 @@ adminRoutes.get('/packs-list', async (c) => { .leftJoin(users, eq(packs.userId, users.id)) .where(eq(packs.deleted, false)) .orderBy(desc(packs.createdAt)) - .limit(100); + .limit(Number(limit)) + .offset(Number(offset)); + + const formattedPacks = packsList.map((pack) => ({ + ...pack, + createdAt: pack.createdAt?.toISOString() || null, + })); - return c.json(packsList); + return c.json(formattedPacks, 200); } catch (error) { console.error('Error fetching packs:', error); - return c.json({ error: 'Failed to fetch packs' }, 500); + return c.json({ error: 'Failed to fetch packs', code: 'PACKS_FETCH_ERROR' }, 500); } }); -adminRoutes.get('/catalog-list', async (c) => { +const getCatalogListRoute = createRoute({ + method: 'get', + path: '/catalog-list', + tags: ['Admin'], + summary: 'List catalog items', + description: 'Get a list of catalog items (Admin only)', + request: { + query: z.object({ + limit: z.number().int().positive().max(100).default(25).optional(), + offset: z.number().int().min(0).default(0).optional(), + }), + }, + responses: { + 200: { + description: 'Catalog items list retrieved successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.number(), + name: z.string(), + categories: z.array(z.string()).nullable(), + brand: z.string().nullable(), + price: z.number().nullable(), + weight: z.number().nullable(), + weightUnit: z.string(), + createdAt: z.string().nullable(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +adminRoutes.openapi(getCatalogListRoute, async (c) => { const db = createDb(c); try { + const { limit = 25, offset = 0 } = c.req.query(); const itemsList = await db .select({ id: catalogItems.id, @@ -826,12 +1003,18 @@ adminRoutes.get('/catalog-list', async (c) => { }) .from(catalogItems) .orderBy(desc(catalogItems.id)) - .limit(25); + .limit(Number(limit)) + .offset(Number(offset)); + + const formattedItems = itemsList.map((item) => ({ + ...item, + createdAt: item.createdAt?.toISOString() || null, + })); - return c.json(itemsList); + return c.json(formattedItems, 200); } catch (error) { console.error('Error fetching catalog items:', error); - return c.json({ error: 'Failed to fetch catalog items' }, 500); + return c.json({ error: 'Failed to fetch catalog items', code: 'CATALOG_FETCH_ERROR' }, 500); } }); diff --git a/packages/api/src/routes/auth/index.ts b/packages/api/src/routes/auth/index.ts index 53cb21ecbc..d24107f023 100644 --- a/packages/api/src/routes/auth/index.ts +++ b/packages/api/src/routes/auth/index.ts @@ -7,7 +7,29 @@ import { refreshTokens, users, } from '@packrat/api/db/schema'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { + AppleAuthRequestSchema, + ErrorResponseSchema, + ForgotPasswordRequestSchema, + ForgotPasswordResponseSchema, + GoogleAuthRequestSchema, + LoginRequestSchema, + LoginResponseSchema, + LogoutRequestSchema, + LogoutResponseSchema, + MeResponseSchema, + RefreshTokenRequestSchema, + RefreshTokenResponseSchema, + RegisterRequestSchema, + RegisterResponseSchema, + ResetPasswordRequestSchema, + ResetPasswordResponseSchema, + SocialAuthResponseSchema, + VerifyEmailRequestSchema, + VerifyEmailResponseSchema, +} from '@packrat/api/schemas/auth'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { generateJWT, generateRefreshToken, @@ -15,6 +37,7 @@ import { hashPassword, validateEmail, validatePassword, + verifyJWT, verifyPassword, } from '@packrat/api/utils/auth'; import { sendPasswordResetEmail, sendVerificationCodeEmail } from '@packrat/api/utils/email'; @@ -23,13 +46,61 @@ import { assertDefined } from '@packrat/api/utils/typeAssertions'; import { and, eq, gt, isNull } from 'drizzle-orm'; import { OAuth2Client } from 'google-auth-library'; -const authRoutes = new OpenAPIHono(); +const authRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Login route const loginRoute = createRoute({ method: 'post', path: '/login', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Login' } }, + tags: ['Authentication'], + summary: 'User login', + description: 'Authenticate a user with email and password to receive access and refresh tokens', + request: { + body: { + content: { + 'application/json': { + schema: LoginRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Successful login', + content: { + 'application/json': { + schema: LoginResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Invalid credentials', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 403: { + description: 'Email not verified', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(loginRoute, async (c) => { @@ -85,26 +156,74 @@ authRoutes.openapi(loginRoute, async (c) => { c, }); - return c.json({ - success: true, - accessToken, - refreshToken, - user: { - id: userRecord.id, - email: userRecord.email, - firstName: userRecord.firstName, - lastName: userRecord.lastName, - emailVerified: userRecord.emailVerified, + return c.json( + { + success: true, + accessToken, + refreshToken, + user: { + id: userRecord.id, + email: userRecord.email, + firstName: userRecord.firstName, + lastName: userRecord.lastName, + emailVerified: userRecord.emailVerified, + }, }, - }); + 200, + ); }); // Register route const registerRoute = createRoute({ method: 'post', path: '/register', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Register user' } }, + tags: ['Authentication'], + summary: 'Register new user', + description: 'Create a new user account and send verification email', + request: { + body: { + content: { + 'application/json': { + schema: RegisterRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'User registered successfully', + content: { + 'application/json': { + schema: RegisterResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 409: { + description: 'Email already in use', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(registerRoute, async (c) => { @@ -122,7 +241,7 @@ authRoutes.openapi(registerRoute, async (c) => { const passwordValidation = validatePassword(password); if (!passwordValidation.valid) { - return c.json({ error: passwordValidation.message }, 400); + return c.json({ error: passwordValidation.message || 'Invalid password' }, 400); } // Check if user already exists @@ -149,7 +268,7 @@ authRoutes.openapi(registerRoute, async (c) => { lastName, emailVerified: false, }) - .returning({ id: users.id }); + .returning(); if (!newUser) { return c.json({ error: 'Failed to create user' }, 500); @@ -167,19 +286,59 @@ authRoutes.openapi(registerRoute, async (c) => { // Send verification email with code await sendVerificationCodeEmail({ to: email, code, c }); - return c.json({ - success: true, - message: 'User registered successfully. Please check your email for your verification code.', - userId: newUser.id, - }); + return c.json( + { + success: true, + message: 'User registered successfully. Please check your email for your verification code.', + userId: newUser.id, + }, + 200, + ); }); // Verify email route const verifyEmailRoute = createRoute({ method: 'post', path: '/verify-email', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Verify email' } }, + tags: ['Authentication'], + summary: 'Verify email address', + description: 'Verify user email with the code sent to their email address', + request: { + body: { + content: { + 'application/json': { + schema: VerifyEmailRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Email verified successfully', + content: { + 'application/json': { + schema: VerifyEmailResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid or expired verification code', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(verifyEmailRoute, async (c) => { @@ -247,35 +406,79 @@ authRoutes.openapi(verifyEmailRoute, async (c) => { c, }); - return c.json({ - success: true, - message: 'Email verified successfully', - accessToken, - refreshToken, - user: { - id: userRecord.id, - email: userRecord.email, - firstName: userRecord.firstName, - lastName: userRecord.lastName, - emailVerified: true, - role: userRecord.role, + return c.json( + { + success: true, + message: 'Email verified successfully', + accessToken, + refreshToken, + user: { + id: userRecord.id, + email: userRecord.email, + firstName: userRecord.firstName, + lastName: userRecord.lastName, + emailVerified: true, + }, }, - }); + 200, + ); }); // Resend verification route const resendVerificationRoute = createRoute({ method: 'post', path: '/resend-verification', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Resend verification code' } }, + tags: ['Authentication'], + summary: 'Resend verification code', + description: 'Resend email verification code to the user', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + email: z.string().email(), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Verification code sent', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean(), + message: z.string(), + }), + }, + }, + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(resendVerificationRoute, async (c) => { const { email } = await c.req.json(); if (!email) { - return Response.json({ error: 'Email is required' }, { status: 400 }); + return c.json({ error: 'Email is required' }, 400); } const db = createDb(c); @@ -284,19 +487,19 @@ authRoutes.openapi(resendVerificationRoute, async (c) => { const user = await db.select().from(users).where(eq(users.email, email.toLowerCase())).limit(1); if (user.length === 0) { - return Response.json({ error: 'User not found' }, { status: 404 }); + return c.json({ error: 'User not found' }, 404); } const userRecord = user[0]; if (!userRecord) { - return Response.json({ error: 'User not found' }, { status: 404 }); + return c.json({ error: 'User not found' }, 404); } const userId = userRecord.id; // Check if user is already verified if (userRecord.emailVerified) { - return Response.json({ error: 'Email is already verified' }, { status: 400 }); + return c.json({ error: 'Email is already verified' }, 400); } // Delete any existing verification codes @@ -315,18 +518,50 @@ authRoutes.openapi(resendVerificationRoute, async (c) => { // Send verification email with code await sendVerificationCodeEmail({ to: email, code, c }); - return Response.json({ - success: true, - message: 'Verification code sent successfully', - }); + return c.json( + { + success: true, + message: 'Verification code sent successfully', + }, + 200, + ); }); // Forgot password route const forgotPasswordRoute = createRoute({ method: 'post', path: '/forgot-password', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Forgot password' } }, + tags: ['Authentication'], + summary: 'Request password reset', + description: 'Send a password reset verification code to the user email', + request: { + body: { + content: { + 'application/json': { + schema: ForgotPasswordRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Password reset code sent if email exists', + content: { + 'application/json': { + schema: ForgotPasswordResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(forgotPasswordRoute, async (c) => { @@ -335,7 +570,7 @@ authRoutes.openapi(forgotPasswordRoute, async (c) => { const db = createDb(c); if (!email) { - return Response.json({ error: 'Email is required' }, { status: 400 }); + return c.json({ error: 'Email is required' }, 400); } // Find user @@ -343,18 +578,24 @@ authRoutes.openapi(forgotPasswordRoute, async (c) => { // Always return success even if user doesn't exist (security best practice) if (user.length === 0) { - return Response.json({ - success: true, - message: 'If your email is registered, you will receive a verification code', - }); + return c.json( + { + success: true, + message: 'If your email is registered, you will receive a verification code', + }, + 200, + ); } const userRecord = user[0]; if (!userRecord) { - return Response.json({ - success: true, - message: 'If your email is registered, you will receive a verification code', - }); + return c.json( + { + success: true, + message: 'If your email is registered, you will receive a verification code', + }, + 200, + ); } // Generate verification code @@ -373,18 +614,58 @@ authRoutes.openapi(forgotPasswordRoute, async (c) => { // Send password reset email with code await sendPasswordResetEmail({ to: email, code, c }); - return Response.json({ - success: true, - message: 'If your email is registered, you will receive a verification code', - }); + return c.json( + { + success: true, + message: 'If your email is registered, you will receive a verification code', + }, + 200, + ); }); // Reset password route const resetPasswordRoute = createRoute({ method: 'post', path: '/reset-password', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Reset password' } }, + tags: ['Authentication'], + summary: 'Reset password', + description: 'Reset user password using verification code', + request: { + body: { + content: { + 'application/json': { + schema: ResetPasswordRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Password reset successfully', + content: { + 'application/json': { + schema: ResetPasswordResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request or expired code', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(resetPasswordRoute, async (c) => { @@ -393,13 +674,13 @@ authRoutes.openapi(resetPasswordRoute, async (c) => { const db = createDb(c); if (!email || !code || !newPassword) { - return Response.json({ error: 'Email, code, and new password are required' }, { status: 400 }); + return c.json({ error: 'Email, code, and new password are required' }, 400); } // Validate password const passwordValidation = validatePassword(newPassword); if (!passwordValidation.valid) { - return Response.json({ error: passwordValidation.message }, { status: 400 }); + return c.json({ error: passwordValidation.message || 'Invalid password' }, 400); } // Find user by email @@ -410,12 +691,12 @@ authRoutes.openapi(resetPasswordRoute, async (c) => { .limit(1); if (userResult.length === 0) { - return Response.json({ error: 'User not found' }, { status: 404 }); + return c.json({ error: 'User not found' }, 404); } const user = userResult[0]; if (!user) { - return Response.json({ error: 'User not found' }, { status: 404 }); + return c.json({ error: 'User not found' }, 404); } // Find verification code @@ -426,17 +707,17 @@ authRoutes.openapi(resetPasswordRoute, async (c) => { .limit(1); if (codeRecord.length === 0) { - return Response.json({ error: 'Invalid verification code' }, { status: 400 }); + return c.json({ error: 'Invalid verification code' }, 400); } const codeRecordItem = codeRecord[0]; if (!codeRecordItem) { - return Response.json({ error: 'Invalid verification code' }, { status: 400 }); + return c.json({ error: 'Invalid verification code' }, 400); } // Check if code is expired if (new Date() > codeRecordItem.expiresAt) { - return Response.json({ error: 'Verification code has expired' }, { status: 400 }); + return c.json({ error: 'Verification code has expired' }, 400); } // Hash new password @@ -448,18 +729,58 @@ authRoutes.openapi(resetPasswordRoute, async (c) => { // Delete the used verification code await db.delete(oneTimePasswords).where(eq(oneTimePasswords.id, codeRecordItem.id)); - return Response.json({ - success: true, - message: 'Password reset successfully', - }); + return c.json( + { + success: true, + message: 'Password reset successfully', + }, + 200, + ); }); // Refresh token route const refreshTokenRoute = createRoute({ method: 'post', path: '/refresh', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Refresh token' } }, + tags: ['Authentication'], + summary: 'Refresh access token', + description: 'Exchange a refresh token for new access and refresh tokens', + request: { + body: { + content: { + 'application/json': { + schema: RefreshTokenRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Tokens refreshed successfully', + content: { + 'application/json': { + schema: RefreshTokenResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Invalid or expired refresh token', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(refreshTokenRoute, async (c) => { @@ -532,7 +853,7 @@ authRoutes.openapi(refreshTokenRoute, async (c) => { .limit(1); if (!user) { - return c.json({ error: 'User not found' }, 404); + return c.json({ error: 'User not found' }, 401); } // Generate new access token @@ -544,15 +865,25 @@ authRoutes.openapi(refreshTokenRoute, async (c) => { c, }); - return c.json({ - success: true, - accessToken, - refreshToken: newRefreshToken, - user, - }); + return c.json( + { + success: true, + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + emailVerified: user.emailVerified, + role: user.role, + }, + }, + 200, + ); } catch (error) { console.error('Token refresh error:', error); - return c.json({ error: 'An error occurred during token refresh' }, 500); + return c.json({ error: 'An error occurred during token refresh' }, 401); } }); @@ -560,8 +891,37 @@ authRoutes.openapi(refreshTokenRoute, async (c) => { const logoutRoute = createRoute({ method: 'post', path: '/logout', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Logout' } }, + tags: ['Authentication'], + summary: 'Logout user', + description: 'Revoke the refresh token to logout the user', + request: { + body: { + content: { + 'application/json': { + schema: LogoutRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Logged out successfully', + content: { + 'application/json': { + schema: LogoutResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(logoutRoute, async (c) => { @@ -580,29 +940,69 @@ authRoutes.openapi(logoutRoute, async (c) => { .set({ revokedAt: new Date() }) .where(eq(refreshTokens.token, refreshToken)); - return c.json({ - success: true, - message: 'Logged out successfully', - }); + return c.json( + { + success: true, + message: 'Logged out successfully', + }, + 200, + ); }); // Me route const meRoute = createRoute({ method: 'get', path: '/me', - responses: { 200: { description: 'Get current user' } }, + tags: ['Authentication'], + summary: 'Get current user', + description: 'Get the authenticated user information', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Current user information', + content: { + 'application/json': { + schema: MeResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(meRoute, async (c) => { try { - const auth = await authenticateRequest(c); + // Extract JWT from Authorization header + const authHeader = c.req.header('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const token = authHeader.substring(7); + const auth = await verifyJWT({ token, c }); const db = createDb(c); if (!auth) { - return unauthorizedResponse(); + return c.json({ error: 'Unauthorized' }, 401); } // Find user + const userId = Number(auth.userId); const user = await db .select({ id: users.id, @@ -612,7 +1012,7 @@ authRoutes.openapi(meRoute, async (c) => { emailVerified: users.emailVerified, }) .from(users) - .where(eq(users.id, auth.userId)) + .where(eq(users.id, userId)) .limit(1); if (user.length === 0) { @@ -624,13 +1024,16 @@ authRoutes.openapi(meRoute, async (c) => { return c.json({ error: 'User not found' }, 404); } - return c.json({ - success: true, - user: userRecord, - }); + return c.json( + { + success: true, + user: userRecord, + }, + 200, + ); } catch (error) { console.error('Get user info error:', error); - return c.json({ error: 'An error occurred' }, 500); + return c.json({ error: 'An error occurred' }, 401); } }); @@ -638,16 +1041,44 @@ authRoutes.openapi(meRoute, async (c) => { const deleteAccountRoute = createRoute({ method: 'delete', path: '/', - responses: { 200: { description: 'Delete account' } }, + tags: ['Authentication'], + summary: 'Delete user account', + description: 'Permanently delete the authenticated user account and all associated data', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Account deleted successfully', + content: { + 'application/json': { + schema: z.object({ success: z.boolean() }), + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); authRoutes.openapi(deleteAccountRoute, async (c) => { - const auth = await authenticateRequest(c); + // Extract JWT from Authorization header + const authHeader = c.req.header('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const token = authHeader.substring(7); + const auth = await verifyJWT({ token, c }); if (!auth) { - return unauthorizedResponse(); + return c.json({ error: 'Unauthorized' }, 401); } const db = createDb(c); - const userId = auth.userId; + const userId = auth.userId as number; // Delete all user-related data in the correct order to respect foreign key constraints @@ -666,25 +1097,43 @@ authRoutes.openapi(deleteAccountRoute, async (c) => { // Finally, delete the user await db.delete(users).where(eq(users.id, userId)); - return c.json({ success: true }); + return c.json({ success: true }, 200); }); const appleRoute = createRoute({ method: 'post', path: '/apple', + tags: ['Authentication'], + summary: 'Sign in with Apple', + description: 'Authenticate or register a user using Sign in with Apple', request: { body: { content: { 'application/json': { - schema: z.object({ - identityToken: z.string(), - authorizationCode: z.string(), - }), + schema: AppleAuthRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Authentication successful', + content: { + 'application/json': { + schema: SocialAuthResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid Apple token', + content: { + 'application/json': { + schema: ErrorResponseSchema, }, }, }, }, - responses: { 200: { description: 'Apple authentication' } }, }); authRoutes.openapi(appleRoute, async (c) => { @@ -722,8 +1171,8 @@ authRoutes.openapi(appleRoute, async (c) => { email, emailVerified: email_verified || false, }) - .returning({ id: users.id }); - userId = newUser.id; + .returning(); + userId = newUser?.id || 0; } await db.insert(authProviders).values({ @@ -754,33 +1203,55 @@ authRoutes.openapi(appleRoute, async (c) => { }); const accessToken = await generateJWT({ - payload: { userId, role: user.role }, + payload: { userId, role: user?.role || 'USER' }, c, }); - return c.json({ - success: true, - accessToken, - refreshToken, - user, - }); + return c.json( + { + success: true, + accessToken, + refreshToken, + user, + }, + 200, + ); }); const googleRoute = createRoute({ method: 'post', path: '/google', + tags: ['Authentication'], + summary: 'Sign in with Google', + description: 'Authenticate or register a user using Google Sign-In', request: { body: { content: { 'application/json': { - schema: z.object({ - idToken: z.string(), - }), + schema: GoogleAuthRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Authentication successful', + content: { + 'application/json': { + schema: SocialAuthResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid Google token', + content: { + 'application/json': { + schema: ErrorResponseSchema, }, }, }, }, - responses: { 200: { description: 'Google authentication' } }, }); authRoutes.openapi(googleRoute, async (c) => { @@ -790,7 +1261,7 @@ authRoutes.openapi(googleRoute, async (c) => { const { idToken } = await c.req.json(); if (!idToken) { - return Response.json({ error: 'ID token is required' }, { status: 400 }); + return c.json({ error: 'ID token is required' }, 400); } const db = createDb(c); @@ -804,7 +1275,7 @@ authRoutes.openapi(googleRoute, async (c) => { const payload = ticket.getPayload(); if (!payload || !payload.email || !payload.sub) { - return Response.json({ error: 'Invalid Google token' }, { status: 400 }); + return c.json({ error: 'Invalid Google token' }, 400); } // Check if user exists with this Google ID @@ -847,7 +1318,7 @@ authRoutes.openapi(googleRoute, async (c) => { lastName: payload.family_name, emailVerified: payload.email_verified || false, }) - .returning({ id: users.id }); + .returning(); assertDefined(newUser); userId = newUser.id; @@ -893,13 +1364,16 @@ authRoutes.openapi(googleRoute, async (c) => { c, }); - return Response.json({ - success: true, - accessToken, - refreshToken, - user, - isNewUser, - }); + return c.json( + { + success: true, + accessToken, + refreshToken, + user, + isNewUser, + }, + 200, + ); }); export { authRoutes }; diff --git a/packages/api/src/routes/catalog/backfillEmbeddingsRoute.ts b/packages/api/src/routes/catalog/backfillEmbeddingsRoute.ts index c2a743cf15..79436997bf 100644 --- a/packages/api/src/routes/catalog/backfillEmbeddingsRoute.ts +++ b/packages/api/src/routes/catalog/backfillEmbeddingsRoute.ts @@ -20,8 +20,11 @@ backfillEmbeddingsRoute.openapi(routeDefinition, async (c) => { const catalogService = new CatalogService(c); const { count } = await catalogService.queueEmbeddingJobs(); - return c.json({ - success: true, - message: `Queued ${count} items`, - }); + return c.json( + { + success: true, + message: `Queued ${count} items`, + }, + 200, + ); }); diff --git a/packages/api/src/routes/catalog/createCatalogItemRoute.ts b/packages/api/src/routes/catalog/createCatalogItemRoute.ts index d265750ee0..bd50dca1e0 100644 --- a/packages/api/src/routes/catalog/createCatalogItemRoute.ts +++ b/packages/api/src/routes/catalog/createCatalogItemRoute.ts @@ -1,34 +1,55 @@ -import { createRoute, z } from '@hono/zod-openapi'; +import { createRoute } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { catalogItems } from '@packrat/api/db/schema'; +import { + CatalogItemSchema, + CreateCatalogItemRequestSchema, + ErrorResponseSchema, +} from '@packrat/api/schemas/catalog'; import { generateEmbedding } from '@packrat/api/services/embeddingService'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; export const routeDefinition = createRoute({ method: 'post', path: '/', + tags: ['Catalog'], + summary: 'Create catalog item', + description: 'Create a new catalog item with automatic embedding generation', + security: [{ bearerAuth: [] }], request: { body: { content: { - 'application/json': { schema: z.any() }, + 'application/json': { schema: CreateCatalogItemRequestSchema }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Catalog item created successfully', + content: { + 'application/json': { + schema: CatalogItemSchema, + }, + }, + }, + 500: { + description: 'Server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, }, }, }, - responses: { 200: { description: 'Create catalog item' } }, }); export const handler: RouteHandler = async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); const data = await c.req.json(); - const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID } = + const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID, AI } = getEnv(c); if (!OPENAI_API_KEY) { @@ -43,6 +64,7 @@ export const handler: RouteHandler = async (c) => { provider: AI_PROVIDER, cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: CLOUDFLARE_AI_GATEWAY_ID, + cloudflareAiBinding: AI, }); const [newItem] = await db @@ -77,5 +99,5 @@ export const handler: RouteHandler = async (c) => { }) .returning(); - return c.json(newItem); + return c.json(newItem, 200); }; diff --git a/packages/api/src/routes/catalog/deleteCatalogItemRoute.ts b/packages/api/src/routes/catalog/deleteCatalogItemRoute.ts index 5c0c7fcc0b..cb2cd45c0c 100644 --- a/packages/api/src/routes/catalog/deleteCatalogItemRoute.ts +++ b/packages/api/src/routes/catalog/deleteCatalogItemRoute.ts @@ -1,23 +1,49 @@ import { createRoute, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { catalogItems } from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { eq } from 'drizzle-orm'; export const routeDefinition = createRoute({ method: 'delete', path: '/{id}', - request: { params: z.object({ id: z.string() }) }, - responses: { 200: { description: 'Delete catalog item' } }, + tags: ['Catalog'], + summary: 'Delete catalog item', + description: 'Delete a catalog item by ID (admin only)', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + id: z.string().openapi({ + example: '123', + description: 'Catalog item ID', + }), + }), + }, + responses: { + 200: { + description: 'Catalog item deleted successfully', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean().openapi({ example: true }), + }), + }, + }, + }, + 404: { + description: 'Catalog item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); export const handler: RouteHandler = async (c) => { // TODO: Only admins should be able to delete catalog items - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } const db = createDb(c); const itemId = Number(c.req.param('id')); @@ -34,5 +60,5 @@ export const handler: RouteHandler = async (c) => { // Delete the catalog item await db.delete(catalogItems).where(eq(catalogItems.id, itemId)); - return c.json({ success: true }); + return c.json({ success: true }, 200); }; diff --git a/packages/api/src/routes/catalog/embeddingsStats.ts b/packages/api/src/routes/catalog/embeddingsStats.ts index 2fc07ed43e..62dd8d6268 100644 --- a/packages/api/src/routes/catalog/embeddingsStats.ts +++ b/packages/api/src/routes/catalog/embeddingsStats.ts @@ -16,15 +16,21 @@ export const routeDefinition = createRoute({ export const handler: RouteHandler = async (c) => { const db = createDb(c); - const [{ totalCount }] = await db + const result = await db .select({ totalCount: count() }) .from(catalogItems) .where(isNull(catalogItems.embedding)); + const withoutEmbeddings = result[0]?.totalCount ?? 0; + const totalItemsResult = await db.select({ totalCount: count() }).from(catalogItems); + const totalItems = totalItemsResult[0]?.totalCount ?? 0; - return c.json({ - itemsWithoutEmbeddings: Number(totalCount), - totalItems: Number(totalItemsResult[0].totalCount), - }); + return c.json( + { + itemsWithoutEmbeddings: Number(withoutEmbeddings), + totalItems: Number(totalItems), + }, + 200, + ); }; diff --git a/packages/api/src/routes/catalog/getCatalogItemRoute.ts b/packages/api/src/routes/catalog/getCatalogItemRoute.ts index ea142d460e..a82d589352 100644 --- a/packages/api/src/routes/catalog/getCatalogItemRoute.ts +++ b/packages/api/src/routes/catalog/getCatalogItemRoute.ts @@ -1,26 +1,51 @@ import { createRoute, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { catalogItems } from '@packrat/api/db/schema'; +import { CatalogItemSchema, ErrorResponseSchema } from '@packrat/api/schemas/catalog'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { eq } from 'drizzle-orm'; export const routeDefinition = createRoute({ method: 'get', path: '/{id}', + tags: ['Catalog'], + summary: 'Get catalog item by ID', + description: 'Retrieve a single catalog item with usage statistics', + security: [{ bearerAuth: [] }], request: { - params: z.object({ id: z.string() }), + params: z.object({ + id: z.string().openapi({ + example: '123', + description: 'Catalog item ID', + }), + }), + }, + responses: { + 200: { + description: 'Catalog item with usage count', + content: { + 'application/json': { + schema: CatalogItemSchema.extend({ + usageCount: z.number().openapi({ + example: 5, + description: 'Number of packs using this item', + }), + }), + }, + }, + }, + 404: { + description: 'Catalog item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Get catalog item' } }, }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); const itemId = Number(c.req.param('id')); @@ -44,8 +69,11 @@ export const handler: RouteHandler = async (c) => { // biome-ignore lint/correctness/noUnusedVariables: removing packItems from result const { packItems, ...itemData } = item; - return c.json({ - ...itemData, - usageCount, - }); + return c.json( + { + ...itemData, + usageCount, + }, + 200, + ); }; diff --git a/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts b/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts index 488789bc67..066f6de6c0 100644 --- a/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts +++ b/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts @@ -1,28 +1,44 @@ -import { createRoute, z } from '@hono/zod-openapi'; +import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { CatalogCategoriesResponseSchema } from '@packrat/api/schemas/catalog'; import { CatalogService } from '@packrat/api/services'; -import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; + +export const getCatalogItemsCategoriesRoute = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); export const routeDefinition = createRoute({ method: 'get', path: '/categories', + tags: ['Catalog'], + summary: 'Get catalog categories', + description: 'Retrieve all available catalog categories with item counts', + security: [{ bearerAuth: [] }], request: { query: z.object({ - limit: z.coerce.number().int().positive().optional().default(10), + limit: z.coerce.number().int().positive().optional().default(10).openapi({ + example: 10, + description: 'Maximum number of categories to return', + }), }), }, - responses: { 200: { description: 'Get catalog categories' } }, + responses: { + 200: { + description: 'List of catalog categories with counts', + content: { + 'application/json': { + schema: CatalogCategoriesResponseSchema, + }, + }, + }, + }, }); -export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - +getCatalogItemsCategoriesRoute.openapi(routeDefinition, async (c) => { const { limit } = c.req.valid('query'); const categories = await new CatalogService(c).getCategories(limit); - return c.json(categories); -}; + return c.json(categories, 200); +}); diff --git a/packages/api/src/routes/catalog/getCatalogItemsRoute.ts b/packages/api/src/routes/catalog/getCatalogItemsRoute.ts index 7fe850a468..a7395e24b9 100644 --- a/packages/api/src/routes/catalog/getCatalogItemsRoute.ts +++ b/packages/api/src/routes/catalog/getCatalogItemsRoute.ts @@ -1,43 +1,31 @@ -import { createRoute, z } from '@hono/zod-openapi'; +import { createRoute } from '@hono/zod-openapi'; +import { CatalogItemsQuerySchema, CatalogItemsResponseSchema } from '@packrat/api/schemas/catalog'; import { CatalogService } from '@packrat/api/services'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; export const routeDefinition = createRoute({ method: 'get', path: '/', + tags: ['Catalog'], + summary: 'Get catalog items', + description: 'Retrieve a paginated list of catalog items with optional filtering and sorting', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().nonnegative().optional().default(20), - q: z.string().optional(), - category: z.string().optional(), - sort: z - .object({ - field: z.enum([ - 'name', - 'brand', - 'category', - 'price', - 'ratingValue', - 'createdAt', - 'updatedAt', - ]), - order: z.enum(['asc', 'desc']), - }) - .optional(), - }), + query: CatalogItemsQuerySchema, + }, + responses: { + 200: { + description: 'List of catalog items', + content: { + 'application/json': { + schema: CatalogItemsResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Get catalog items' } }, }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const { page, limit, q, category: encodedCategory } = c.req.valid('query'); let category: string | undefined; if (typeof encodedCategory === 'string' && encodedCategory.length > 0) { @@ -57,7 +45,6 @@ export const handler: RouteHandler = async (c) => { const validSortFields = [ 'name', 'brand', - 'category', 'price', 'ratingValue', 'createdAt', @@ -90,11 +77,14 @@ export const handler: RouteHandler = async (c) => { const totalPages = Math.ceil(result.total / limit); - return c.json({ - items: result.items, - totalCount: result.total, - page, - limit, - totalPages, - }); + return c.json( + { + items: result.items, + totalCount: result.total, + page, + limit, + totalPages, + }, + 200, + ); }; diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index a1b7ac1569..2a8996f08e 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -1,10 +1,10 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { backfillEmbeddingsRoute } from './backfillEmbeddingsRoute'; import * as createCatalogItemRoute from './createCatalogItemRoute'; import * as deleteCatalogItemRoute from './deleteCatalogItemRoute'; import * as getCatalogItemRoute from './getCatalogItemRoute'; -import * as getCatalogItemsCategoriesRoute from './getCatalogItemsCategoriesRoute'; +import { getCatalogItemsCategoriesRoute } from './getCatalogItemsCategoriesRoute'; import * as getCatalogItemsRoute from './getCatalogItemsRoute'; import * as queueCatalogEtlRoute from './queueCatalogEtlRoute'; import * as updateCatalogItemRoute from './updateCatalogItemRoute'; @@ -13,10 +13,7 @@ const catalogRoutes = new OpenAPIHono<{ Bindings: Env }>(); catalogRoutes.openapi(getCatalogItemsRoute.routeDefinition, getCatalogItemsRoute.handler); catalogRoutes.openapi(createCatalogItemRoute.routeDefinition, createCatalogItemRoute.handler); -catalogRoutes.openapi( - getCatalogItemsCategoriesRoute.routeDefinition, - getCatalogItemsCategoriesRoute.handler, -); +catalogRoutes.route('/', getCatalogItemsCategoriesRoute); catalogRoutes.openapi(getCatalogItemRoute.routeDefinition, getCatalogItemRoute.handler); catalogRoutes.openapi(deleteCatalogItemRoute.routeDefinition, deleteCatalogItemRoute.handler); catalogRoutes.openapi(updateCatalogItemRoute.routeDefinition, updateCatalogItemRoute.handler); diff --git a/packages/api/src/routes/catalog/queueCatalogEtlRoute.ts b/packages/api/src/routes/catalog/queueCatalogEtlRoute.ts index 62d1808083..95a25fa414 100644 --- a/packages/api/src/routes/catalog/queueCatalogEtlRoute.ts +++ b/packages/api/src/routes/catalog/queueCatalogEtlRoute.ts @@ -87,9 +87,14 @@ export const handler: RouteHandler = async (c) => { jobId, }); - return c.json({ - message: 'Catalog ETL job queued successfully', - jobId, - queued: true, - }); + console.log(`πŸš€ Initiated ETL job ${jobId} for file ${objectKey}`); + + return c.json( + { + message: 'Catalog ETL job queued successfully', + jobId, + queued: true, + }, + 200, + ); }; diff --git a/packages/api/src/routes/catalog/updateCatalogItemRoute.ts b/packages/api/src/routes/catalog/updateCatalogItemRoute.ts index 4beadf8e28..dbf8972036 100644 --- a/packages/api/src/routes/catalog/updateCatalogItemRoute.ts +++ b/packages/api/src/routes/catalog/updateCatalogItemRoute.ts @@ -1,9 +1,13 @@ import { createRoute, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { catalogItems } from '@packrat/api/db/schema'; +import { + CatalogItemSchema, + ErrorResponseSchema, + UpdateCatalogItemRequestSchema, +} from '@packrat/api/schemas/catalog'; import { generateEmbedding } from '@packrat/api/services/embeddingService'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { eq } from 'drizzle-orm'; @@ -11,26 +15,58 @@ import { eq } from 'drizzle-orm'; export const routeDefinition = createRoute({ method: 'put', path: '/{id}', + tags: ['Catalog'], + summary: 'Update catalog item', + description: 'Update an existing catalog item with automatic embedding regeneration if needed', + security: [{ bearerAuth: [] }], request: { - params: z.object({ id: z.string() }), + params: z.object({ + id: z.string().openapi({ + example: '123', + description: 'Catalog item ID', + }), + }), body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { schema: UpdateCatalogItemRequestSchema }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Catalog item updated successfully', + content: { + 'application/json': { + schema: CatalogItemSchema, + }, + }, + }, + 404: { + description: 'Catalog item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 200: { description: 'Update catalog item' } }, }); export const handler: RouteHandler = async (c) => { // TODO: Only admins should be able to update catalog items - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); const itemId = Number(c.req.param('id')); const data = await c.req.json(); - const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID } = + const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID, AI } = getEnv(c); if (!OPENAI_API_KEY) { @@ -46,7 +82,7 @@ export const handler: RouteHandler = async (c) => { } // Only generate a new embedding if the text has changed - let embedding: number[] | undefined; + let embedding: number[] | null = null; const newEmbeddingText = getEmbeddingText(data, existingItem); const oldEmbeddingText = getEmbeddingText(existingItem); @@ -57,6 +93,7 @@ export const handler: RouteHandler = async (c) => { provider: AI_PROVIDER, cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: CLOUDFLARE_AI_GATEWAY_ID, + cloudflareAiBinding: AI, }); } @@ -73,5 +110,5 @@ export const handler: RouteHandler = async (c) => { .where(eq(catalogItems.id, itemId)) .returning(); - return c.json(updatedItem); + return c.json(updatedItem, 200); }; diff --git a/packages/api/src/routes/chat.ts b/packages/api/src/routes/chat.ts index 42976e9fbc..a173154721 100644 --- a/packages/api/src/routes/chat.ts +++ b/packages/api/src/routes/chat.ts @@ -1,41 +1,78 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { reportedContent } from '@packrat/api/db/schema'; +import { + ChatRequestSchema, + CreateReportRequestSchema, + ErrorResponseSchema, + ReportsResponseSchema, + SuccessResponseSchema, + UpdateReportStatusRequestSchema, +} from '@packrat/api/schemas/chat'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { createAIProvider } from '@packrat/api/utils/ai/provider'; import { createTools } from '@packrat/api/utils/ai/tools'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEnv } from '@packrat/api/utils/env-validation'; import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai'; import { eq } from 'drizzle-orm'; import { DEFAULT_MODELS } from '../utils/ai/models'; import { getSchemaInfo } from '../utils/DbUtils'; -const chatRoutes = new OpenAPIHono(); +const chatRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); const chatRoute = createRoute({ method: 'post', path: '/', + tags: ['Chat'], + summary: 'Chat with AI assistant', + description: 'Send messages to the PackRat AI assistant for hiking and outdoor gear advice', + security: [{ bearerAuth: [] }], request: { body: { content: { 'application/json': { - schema: z.any(), + schema: ChatRequestSchema, }, }, + required: true, }, }, responses: { 200: { - description: 'Chat response', + description: 'Streaming AI response', + content: { + 'text/plain': { + schema: z.string().openapi({ + description: 'Streaming text response from the AI', + }), + }, + }, + }, + 400: { + description: 'Bad request - Invalid message format', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, }); chatRoutes.openapi(chatRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); let body: { messages?: UIMessage[] | undefined; @@ -95,11 +132,15 @@ chatRoutes.openapi(chatRoute, async (c) => { cloudflareAiBinding: AI, }); + if (!aiProvider) { + return c.json({ error: 'AI provider not configured' }, 500); + } + // Stream the AI response const result = streamText({ model: aiProvider(DEFAULT_MODELS.OPENAI_CHAT), system: systemPrompt, - messages: convertToModelMessages(messages), + messages: convertToModelMessages(messages || []), tools, maxOutputTokens: 1000, temperature: 0.7, @@ -139,11 +180,45 @@ chatRoutes.openapi(chatRoute, async (c) => { } }); -chatRoutes.post('/reports', async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } +const createReportRoute = createRoute({ + method: 'post', + path: '/reports', + tags: ['Chat'], + summary: 'Report AI content', + description: 'Report inappropriate or problematic AI responses for review', + security: [{ bearerAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: CreateReportRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Report submitted successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +chatRoutes.openapi(createReportRoute, async (c) => { + const auth = c.get('user'); const db = createDb(c); @@ -158,15 +233,47 @@ chatRoutes.post('/reports', async (c) => { userComment, }); - return c.json({ success: true }); + return c.json({ success: true }, 200); }); // Get all reported content (admin only) - separate endpoint -chatRoutes.get('/reports', async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } +const getReportsRoute = createRoute({ + method: 'get', + path: '/reports', + tags: ['Chat'], + summary: 'Get reported content (Admin)', + description: 'Retrieve all reported AI content for admin review', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'List of reported content', + content: { + 'application/json': { + schema: ReportsResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Admin access required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +chatRoutes.openapi(getReportsRoute, async (c) => { + const auth = c.get('user'); const db = createDb(c); @@ -183,15 +290,77 @@ chatRoutes.get('/reports', async (c) => { }, }); - return c.json({ reportedItems }); + // Add updatedAt field (using createdAt as fallback since table doesn't have updatedAt) + const reportedItemsWithUpdatedAt = reportedItems.map((item) => ({ + ...item, + updatedAt: item.createdAt, + })); + + return c.json({ reportedItems: reportedItemsWithUpdatedAt }, 200); }); // Update reported content status (admin only) -chatRoutes.patch('/reports/:id', async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } +const updateReportRoute = createRoute({ + method: 'patch', + path: '/reports/{id}', + tags: ['Chat'], + summary: 'Update report status (Admin)', + description: 'Update the status of a reported content item (admin only)', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + id: z.string().openapi({ + example: '123', + description: 'The ID of the report to update', + }), + }), + body: { + content: { + 'application/json': { + schema: UpdateReportStatusRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Report status updated successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Admin access required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Report not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +chatRoutes.openapi(updateReportRoute, async (c) => { + const auth = c.get('user'); const db = createDb(c); @@ -214,7 +383,7 @@ chatRoutes.patch('/reports/:id', async (c) => { }) .where(eq(reportedContent.id, id)); - return c.json({ success: true }); + return c.json({ success: true }, 200); }); export { chatRoutes }; diff --git a/packages/api/src/routes/guides/getCategoriesRoute.ts b/packages/api/src/routes/guides/getCategoriesRoute.ts index 7ac8a948e8..7f033398ae 100644 --- a/packages/api/src/routes/guides/getCategoriesRoute.ts +++ b/packages/api/src/routes/guides/getCategoriesRoute.ts @@ -1,7 +1,6 @@ import { createRoute } from '@hono/zod-openapi'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEnv } from '@packrat/api/utils/env-validation'; import matter from 'gray-matter'; @@ -12,12 +11,6 @@ export const routeDefinition = createRoute({ }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - try { const bucket = new R2BucketService({ env: getEnv(c), @@ -62,10 +55,13 @@ export const handler: RouteHandler = async (c) => { // Convert set to sorted array const categories = Array.from(categoriesSet).sort(); - return c.json({ - categories, - count: categories.length, - }); + return c.json( + { + categories, + count: categories.length, + }, + 200, + ); } catch (error) { console.error('Error getting guide categories:', error); return c.json({ error: 'Failed to get guide categories' }, 500); diff --git a/packages/api/src/routes/guides/getGuideRoute.ts b/packages/api/src/routes/guides/getGuideRoute.ts index a947eba256..f7d7df8470 100644 --- a/packages/api/src/routes/guides/getGuideRoute.ts +++ b/packages/api/src/routes/guides/getGuideRoute.ts @@ -1,31 +1,54 @@ import { createRoute, z } from '@hono/zod-openapi'; +import { ErrorResponseSchema, GuideDetailSchema } from '@packrat/api/schemas/guides'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEnv } from '@packrat/api/utils/env-validation'; import matter from 'gray-matter'; export const routeDefinition = createRoute({ method: 'get', path: '/{id}', + tags: ['Guides'], + summary: 'Get a specific guide', + description: 'Retrieve detailed content for a specific guide by its ID', + security: [{ bearerAuth: [] }], request: { params: z.object({ - id: z.string(), + id: z.string().openapi({ + example: 'ultralight-backpacking', + description: 'The unique identifier of the guide', + }), }), }, responses: { - 200: { description: 'Get guide content' }, - 404: { description: 'Guide not found' }, + 200: { + description: 'Guide retrieved successfully', + content: { + 'application/json': { + schema: GuideDetailSchema, + }, + }, + }, + 404: { + description: 'Guide not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const { id } = c.req.valid('param'); try { @@ -57,19 +80,23 @@ export const handler: RouteHandler = async (c) => { // Parse frontmatter const { data: frontmatter, content } = matter(rawContent); - return c.json({ - id, - title: frontmatter.title || metadata.title || id.replace(/-/g, ' '), - category: metadata.category || 'general', - categories: frontmatter.categories || [], - description: frontmatter.description || metadata.description || '', - author: frontmatter.author, - readingTime: frontmatter.readingTime, - difficulty: frontmatter.difficulty, - content, - createdAt: object.uploaded.toISOString(), - updatedAt: object.uploaded.toISOString(), - }); + return c.json( + { + id, + key, // Add the key field that the schema expects + title: frontmatter.title || metadata.title || id.replace(/-/g, ' '), + category: metadata.category || 'general', + categories: frontmatter.categories || [], + description: frontmatter.description || metadata.description || '', + author: frontmatter.author, + readingTime: frontmatter.readingTime, + difficulty: frontmatter.difficulty, + content, + createdAt: object.uploaded.toISOString(), + updatedAt: object.uploaded.toISOString(), + }, + 200, + ); } catch (error) { console.error('Error fetching guide:', error); return c.json({ error: 'Failed to fetch guide' }, 500); diff --git a/packages/api/src/routes/guides/getGuidesRoute.ts b/packages/api/src/routes/guides/getGuidesRoute.ts index 79e9726ec3..a00e4715b2 100644 --- a/packages/api/src/routes/guides/getGuidesRoute.ts +++ b/packages/api/src/routes/guides/getGuidesRoute.ts @@ -1,36 +1,47 @@ -import { createRoute, z } from '@hono/zod-openapi'; +import { createRoute } from '@hono/zod-openapi'; +import { + ErrorResponseSchema, + GuidesQuerySchema, + GuidesResponseSchema, +} from '@packrat/api/schemas/guides'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEnv } from '@packrat/api/utils/env-validation'; import matter from 'gray-matter'; +import { isArray } from 'radash'; export const routeDefinition = createRoute({ method: 'get', path: '/', + tags: ['Guides'], + summary: 'Get all guides', + description: + 'Retrieve a paginated list of all available guides with optional filtering and sorting', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().nonnegative().optional().default(20), - category: z.string().optional(), - sort: z - .object({ - field: z.enum(['title', 'category', 'createdAt', 'updatedAt']), - order: z.enum(['asc', 'desc']), - }) - .optional(), - }), + query: GuidesQuerySchema, + }, + responses: { + 200: { + description: 'Guides retrieved successfully', + content: { + 'application/json': { + schema: GuidesResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Get guides list' } }, }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const { page, limit, category } = c.req.valid('query'); // Manually parse sort parameters from raw query @@ -100,15 +111,17 @@ export const handler: RouteHandler = async (c) => { let filteredGuides = guides; if (category) { filteredGuides = guides.filter( - (guide) => guide.category === category || guide.categories?.includes(category), + (guide) => + guide.category === category || + (isArray(guide.categories) && guide.categories.includes(category)), ); } // Apply sorting if (sort) { filteredGuides.sort((a, b) => { - const aValue = a[sort.field as keyof typeof a]; - const bValue = b[sort.field as keyof typeof b]; + const aValue = String(a[sort.field as keyof typeof a]); + const bValue = String(b[sort.field as keyof typeof b]); if (sort.order === 'asc') { return aValue < bValue ? -1 : aValue > bValue ? 1 : 0; @@ -118,7 +131,7 @@ export const handler: RouteHandler = async (c) => { }); } else { // Default sort by title - filteredGuides.sort((a, b) => a.title.localeCompare(b.title)); + filteredGuides.sort((a, b) => String(a.title).localeCompare(String(b.title))); } // Apply pagination @@ -127,13 +140,16 @@ export const handler: RouteHandler = async (c) => { const paginatedGuides = filteredGuides.slice(offset, offset + limit); const totalPages = Math.ceil(total / limit); - return c.json({ - items: paginatedGuides, - totalCount: total, - page, - limit, - totalPages, - }); + return c.json( + { + items: paginatedGuides, + totalCount: total, + page, + limit, + totalPages, + }, + 200, + ); } catch (error) { console.error('Error listing guides:', error); return c.json({ error: 'Failed to list guides' }, 500); diff --git a/packages/api/src/routes/guides/index.ts b/packages/api/src/routes/guides/index.ts index 0cd5966feb..5956fe524d 100644 --- a/packages/api/src/routes/guides/index.ts +++ b/packages/api/src/routes/guides/index.ts @@ -1,5 +1,5 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import * as getCategoriesRoute from './getCategoriesRoute'; import * as getGuideRoute from './getGuideRoute'; import * as getGuidesRoute from './getGuidesRoute'; diff --git a/packages/api/src/routes/guides/searchGuidesRoute.ts b/packages/api/src/routes/guides/searchGuidesRoute.ts index df05c5878a..428af015c2 100644 --- a/packages/api/src/routes/guides/searchGuidesRoute.ts +++ b/packages/api/src/routes/guides/searchGuidesRoute.ts @@ -1,30 +1,52 @@ -import { createRoute, z } from '@hono/zod-openapi'; +import { createRoute } from '@hono/zod-openapi'; +import { + ErrorResponseSchema, + GuideSearchQuerySchema, + GuideSearchResponseSchema, +} from '@packrat/api/schemas/guides'; import { R2BucketService } from '@packrat/api/services/r2-bucket'; import type { RouteHandler } from '@packrat/api/types/routeHandler'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; import { getEnv } from '@packrat/api/utils/env-validation'; export const routeDefinition = createRoute({ method: 'get', path: '/search', + tags: ['Guides'], + summary: 'Search guides', + description: 'Search through guide titles, descriptions, and content using text matching', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - q: z.string().min(1), - page: z.coerce.number().int().positive().optional().default(1), - limit: z.coerce.number().int().nonnegative().optional().default(20), - category: z.string().optional(), - }), + query: GuideSearchQuerySchema, + }, + responses: { + 200: { + description: 'Search results retrieved successfully', + content: { + 'application/json': { + schema: GuideSearchResponseSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid search query', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Search guides' } }, }); export const handler: RouteHandler = async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const { q, page, limit, category } = c.req.valid('query'); const searchQuery = q.toLowerCase(); @@ -102,14 +124,17 @@ export const handler: RouteHandler = async (c) => { const paginatedGuides = guides.slice(offset, offset + limit); const totalPages = Math.ceil(total / limit); - return c.json({ - items: paginatedGuides, - totalCount: total, - page, - limit, - totalPages, - query: q, - }); + return c.json( + { + items: paginatedGuides, + totalCount: total, + page, + limit, + totalPages, + query: q, + }, + 200, + ); } catch (error) { console.error('Error searching guides:', error); return c.json({ error: 'Failed to search guides' }, 500); diff --git a/packages/api/src/routes/knowledgeBase/index.ts b/packages/api/src/routes/knowledgeBase/index.ts index 9bb8ba442f..ea489a0eef 100644 --- a/packages/api/src/routes/knowledgeBase/index.ts +++ b/packages/api/src/routes/knowledgeBase/index.ts @@ -1,5 +1,5 @@ import { OpenAPIHono } from '@hono/zod-openapi'; -import type { Env } from '../../types/env'; +import type { Env } from '@packrat/api/types/env'; import { readerRoutes } from './reader'; const knowledgeBaseRoutes = new OpenAPIHono<{ Bindings: Env }>(); diff --git a/packages/api/src/routes/knowledgeBase/reader.ts b/packages/api/src/routes/knowledgeBase/reader.ts index 900579efbe..1857bc87dd 100644 --- a/packages/api/src/routes/knowledgeBase/reader.ts +++ b/packages/api/src/routes/knowledgeBase/reader.ts @@ -1,6 +1,6 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { Readability } from '@mozilla/readability'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { parseHTML } from 'linkedom'; const readerRoutes = new OpenAPIHono<{ Bindings: Env }>(); @@ -41,9 +41,6 @@ const extractContentRoute = createRoute({ 400: { description: 'Bad Request', }, - 401: { - description: 'Unauthorized', - }, 500: { description: 'Internal Server Error', }, @@ -125,7 +122,7 @@ readerRoutes.openapi(extractContentRoute, async (c) => { const cleanedText = cleanTextForEmbedding(article.textContent || ''); // Convert HTML to Markdown using our pure function - let markdown = null; + let markdown: string | null = null; try { markdown = htmlToMarkdown(article.content || ''); } catch (err) { diff --git a/packages/api/src/routes/packTemplates/index.ts b/packages/api/src/routes/packTemplates/index.ts index 9b68f76898..bc98a25ecc 100644 --- a/packages/api/src/routes/packTemplates/index.ts +++ b/packages/api/src/routes/packTemplates/index.ts @@ -1,8 +1,8 @@ -import { Hono } from 'hono'; +import { OpenAPIHono } from '@hono/zod-openapi'; import { packTemplateItemsRoutes } from './packTemplateItems'; import { packTemplateRoutes } from './packTemplates'; -const packTemplatesRoutes = new Hono(); +const packTemplatesRoutes = new OpenAPIHono(); packTemplatesRoutes.route('/', packTemplateRoutes); packTemplatesRoutes.route('/', packTemplateItemsRoutes); diff --git a/packages/api/src/routes/packTemplates/packTemplateItems.ts b/packages/api/src/routes/packTemplates/packTemplateItems.ts index 858e3e520a..a223026384 100644 --- a/packages/api/src/routes/packTemplates/packTemplateItems.ts +++ b/packages/api/src/routes/packTemplates/packTemplateItems.ts @@ -1,23 +1,77 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { packTemplateItems, packTemplates } from '@packrat/api/db/schema'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { + CreatePackTemplateItemRequestSchema, + ErrorResponseSchema, + PackTemplateItemSchema, + SuccessResponseSchema, + UpdatePackTemplateItemRequestSchema, +} from '@packrat/api/schemas/packTemplates'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { and, eq, or } from 'drizzle-orm'; import { z } from 'zod'; -const packTemplateItemsRoutes = new OpenAPIHono(); +const packTemplateItemsRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Get all items for a template const getItemsRoute = createRoute({ method: 'get', path: '/{templateId}/items', - request: { params: z.object({ templateId: z.string() }) }, - responses: { 200: { description: 'Get all items for a template' } }, + tags: ['Pack Templates'], + summary: 'Get all items for a template', + description: 'Retrieve all items for a specific pack template', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + templateId: z.string().openapi({ + example: 'pt_123456', + description: 'The unique identifier of the pack template', + }), + }), + }, + responses: { + 200: { + description: 'Template items retrieved successfully', + content: { + 'application/json': { + schema: z.array(PackTemplateItemSchema), + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packTemplateItemsRoutes.openapi(getItemsRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const templateId = c.req.param('templateId'); @@ -36,25 +90,79 @@ packTemplateItemsRoutes.openapi(getItemsRoute, async (c) => { ), ); - return c.json(items); + return c.json(items, 200); }); // Add item to template const addItemRoute = createRoute({ method: 'post', path: '/{templateId}/items', + tags: ['Pack Templates'], + summary: 'Add item to template', + description: 'Add a new item to a specific pack template', + security: [{ bearerAuth: [] }], request: { - params: z.object({ templateId: z.string() }), + params: z.object({ + templateId: z.string().openapi({ + example: 'pt_123456', + description: 'The unique identifier of the pack template', + }), + }), body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { + schema: CreatePackTemplateItemRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 201: { + description: 'Item added to template successfully', + content: { + 'application/json': { + schema: PackTemplateItemSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 201: { description: 'Add item to template' } }, }); packTemplateItemsRoutes.openapi(addItemRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const templateId = c.req.param('templateId'); @@ -94,25 +202,79 @@ packTemplateItemsRoutes.openapi(addItemRoute, async (c) => { .set({ updatedAt: new Date() }) .where(eq(packTemplates.id, templateId)); - return c.json(newItem); + return c.json(newItem, 201); }); // Update a template item const updateItemRoute = createRoute({ method: 'patch', path: '/items/{itemId}', + tags: ['Pack Templates'], + summary: 'Update a template item', + description: 'Update a specific item in a pack template', + security: [{ bearerAuth: [] }], request: { - params: z.object({ itemId: z.string() }), + params: z.object({ + itemId: z.string().openapi({ + example: 'pti_123456', + description: 'The unique identifier of the template item', + }), + }), body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { + schema: UpdatePackTemplateItemRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Template item updated successfully', + content: { + 'application/json': { + schema: PackTemplateItemSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template or item', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 200: { description: 'Update a template item' } }, }); packTemplateItemsRoutes.openapi(updateItemRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const itemId = c.req.param('itemId'); @@ -158,7 +320,96 @@ packTemplateItemsRoutes.openapi(updateItemRoute, async (c) => { ) .returning(); - return c.json(updatedItem); + return c.json(updatedItem, 200); +}); + +// Delete a template item +const deleteItemRoute = createRoute({ + method: 'delete', + path: '/items/{itemId}', + tags: ['Pack Templates'], + summary: 'Delete a template item', + description: 'Delete a specific item from a pack template', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + itemId: z.string().openapi({ + example: 'pti_123456', + description: 'The unique identifier of the template item', + }), + }), + }, + responses: { + 200: { + description: 'Template item deleted successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template or item', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +packTemplateItemsRoutes.openapi(deleteItemRoute, async (c) => { + const auth = c.get('user'); + + const db = createDb(c); + const itemId = c.req.param('itemId'); + + const item = await db.query.packTemplateItems.findFirst({ + where: eq(packTemplateItems.id, itemId), + with: { + template: true, // include the template to check permissions + }, + }); + + if (!item) return c.json({ error: 'Item not found' }, 404); + if (item.template.isAppTemplate && auth.role !== 'ADMIN') { + return c.json({ error: 'Not allowed' }, 403); + } + + // Check if user owns the item or is admin for app template + const canDelete = + (item.template.isAppTemplate && auth.role === 'ADMIN') || item.userId === auth.userId; + + if (!canDelete) { + return c.json({ error: 'Not allowed' }, 403); + } + + await db.delete(packTemplateItems).where(eq(packTemplateItems.id, itemId)); + + // Update the parent template's updatedAt timestamp + await db + .update(packTemplates) + .set({ updatedAt: new Date() }) + .where(eq(packTemplates.id, item.packTemplateId)); + + return c.json({ success: true }, 200); }); export { packTemplateItemsRoutes }; diff --git a/packages/api/src/routes/packTemplates/packTemplates.ts b/packages/api/src/routes/packTemplates/packTemplates.ts index 87f306f9fd..77004f0a46 100644 --- a/packages/api/src/routes/packTemplates/packTemplates.ts +++ b/packages/api/src/routes/packTemplates/packTemplates.ts @@ -1,21 +1,53 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { type PackTemplate, packTemplates } from '@packrat/api/db/schema'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { + CreatePackTemplateRequestSchema, + ErrorResponseSchema, + PackTemplateWithItemsSchema, + SuccessResponseSchema, + UpdatePackTemplateRequestSchema, +} from '@packrat/api/schemas/packTemplates'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { and, eq, or } from 'drizzle-orm'; -const packTemplateRoutes = new OpenAPIHono(); +const packTemplateRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Get all templates const getTemplatesRoute = createRoute({ method: 'get', path: '/', - responses: { 200: { description: 'Get all pack templates' } }, + tags: ['Pack Templates'], + summary: 'Get all pack templates', + description: + 'Retrieve all pack templates accessible to the authenticated user (own templates and app templates)', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Pack templates retrieved successfully', + content: { + 'application/json': { + schema: z.array(PackTemplateWithItemsSchema), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packTemplateRoutes.openapi(getTemplatesRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const templates = await db.query.packTemplates.findMany({ @@ -26,24 +58,57 @@ packTemplateRoutes.openapi(getTemplatesRoute, async (c) => { with: { items: true }, }); - return c.json(templates); + return c.json(templates, 200); }); // Create a new template const createTemplateRoute = createRoute({ method: 'post', path: '/', + tags: ['Pack Templates'], + summary: 'Create a new pack template', + description: 'Create a new pack template for the authenticated user', + security: [{ bearerAuth: [] }], request: { body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { + schema: CreatePackTemplateRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 201: { + description: 'Pack template created successfully', + content: { + 'application/json': { + schema: PackTemplateWithItemsSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 201: { description: 'Create a new pack template' } }, }); packTemplateRoutes.openapi(createTemplateRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const data = await c.req.json(); @@ -66,22 +131,63 @@ packTemplateRoutes.openapi(createTemplateRoute, async (c) => { }) .returning(); - return c.json(newTemplate); + return c.json(newTemplate, 201); }); // Get a specific pack template const getTemplateRoute = createRoute({ method: 'get', path: '/{templateId}', + tags: ['Pack Templates'], + summary: 'Get a specific pack template', + description: 'Retrieve a specific pack template by ID with all its items', + security: [{ bearerAuth: [] }], request: { - params: z.object({ templateId: z.string() }), + params: z.object({ + templateId: z.string().openapi({ + example: 'pt_123456', + description: 'The unique identifier of the pack template', + }), + }), + }, + responses: { + 200: { + description: 'Pack template retrieved successfully', + content: { + 'application/json': { + schema: PackTemplateWithItemsSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Get a specific pack template' } }, }); packTemplateRoutes.openapi(getTemplateRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const templateId = c.req.param('templateId'); @@ -99,26 +205,79 @@ packTemplateRoutes.openapi(getTemplateRoute, async (c) => { }); if (!template) return c.json({ error: 'Template not found' }, 404); - return c.json(template); + return c.json(template, 200); }); // Update a pack template const updateTemplateRoute = createRoute({ method: 'put', path: '/{templateId}', + tags: ['Pack Templates'], + summary: 'Update a pack template', + description: 'Update a specific pack template by ID', + security: [{ bearerAuth: [] }], request: { - params: z.object({ templateId: z.string() }), + params: z.object({ + templateId: z.string().openapi({ + example: 'pt_123456', + description: 'The unique identifier of the pack template', + }), + }), body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { + schema: UpdatePackTemplateRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Pack template updated successfully', + content: { + 'application/json': { + schema: PackTemplateWithItemsSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 200: { description: 'Update a pack template' } }, }); packTemplateRoutes.openapi(updateTemplateRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); - + const auth = c.get('user'); const db = createDb(c); const templateId = c.req.param('templateId'); const data = await c.req.json(); @@ -153,22 +312,63 @@ packTemplateRoutes.openapi(updateTemplateRoute, async (c) => { }); if (!updated) return c.json({ error: 'Template not found' }, 404); - return c.json(updated); + return c.json(updated, 200); }); // Delete a pack template const deleteTemplateRoute = createRoute({ method: 'delete', path: '/{templateId}', + tags: ['Pack Templates'], + summary: 'Delete a pack template', + description: 'Delete a specific pack template by ID', + security: [{ bearerAuth: [] }], request: { - params: z.object({ templateId: z.string() }), + params: z.object({ + templateId: z.string().openapi({ + example: 'pt_123456', + description: 'The unique identifier of the pack template', + }), + }), + }, + responses: { + 200: { + description: 'Pack template deleted successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - Access denied to this template', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Template not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Delete a pack template' } }, }); packTemplateRoutes.openapi(deleteTemplateRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const templateId = c.req.param('templateId'); @@ -189,7 +389,7 @@ packTemplateRoutes.openapi(deleteTemplateRoute, async (c) => { and(eq(packTemplates.id, templateId), eq(packTemplates.userId, auth.userId)), ); - return c.json({ success: true }); + return c.json({ success: true }, 200); }); export { packTemplateRoutes }; diff --git a/packages/api/src/routes/packs/generatePacksRoute.ts b/packages/api/src/routes/packs/generatePacksRoute.ts index 421efe847f..39cecb33fc 100644 --- a/packages/api/src/routes/packs/generatePacksRoute.ts +++ b/packages/api/src/routes/packs/generatePacksRoute.ts @@ -1,5 +1,7 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; import { adminMiddleware } from '@packrat/api/middleware/adminMiddleware'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { PackSchema } from '@packrat/api/schemas/packs'; import { PackService } from '@packrat/api/services/packService'; import type { Env } from '@packrat/api/types/env'; import type { Variables } from '@packrat/api/types/variables'; @@ -10,23 +12,48 @@ const generatePacksRoute = new OpenAPIHono<{ Bindings: Env; Variables: Variables const route = createRoute({ method: 'post', path: '/generate-packs', + tags: ['Packs'], + summary: 'Generate sample packs (Admin only)', + description: + 'Generate sample packs with AI-powered content for testing and demonstration purposes. Requires admin privileges.', + security: [{ bearerAuth: [] }], request: { body: { content: { 'application/json': { schema: z.object({ - count: z.number().int().positive().default(1), + count: z.number().int().positive().default(1).openapi({ + example: 5, + description: 'Number of packs to generate', + }), }), }, }, + required: true, }, }, responses: { 200: { - description: 'Successfully generated packs.', + description: 'Packs generated successfully', content: { 'application/json': { - schema: z.any(), + schema: z.array(PackSchema), + }, + }, + }, + 403: { + description: 'Forbidden - admin access required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, }, }, }, @@ -38,10 +65,10 @@ generatePacksRoute.use('*', adminMiddleware); generatePacksRoute.openapi(route, async (c) => { const { count } = c.req.valid('json'); const user = c.get('user'); - const packService = new PackService(c, user.id); + const packService = new PackService(c, user.userId); const generatedPacks = await packService.generatePacks(count); - return c.json(generatedPacks); + return c.json(generatedPacks, 200); }); export { generatePacksRoute }; diff --git a/packages/api/src/routes/packs/items.ts b/packages/api/src/routes/packs/items.ts index 4ab4af358e..a6419cc06a 100644 --- a/packages/api/src/routes/packs/items.ts +++ b/packages/api/src/routes/packs/items.ts @@ -1,27 +1,68 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { packItems, packs } from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { + CreatePackItemRequestSchema, + PackItemSchema, + UpdatePackItemRequestSchema, +} from '@packrat/api/schemas/packs'; import { generateEmbedding } from '@packrat/api/services/embeddingService'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; import { getEnv } from '@packrat/api/utils/env-validation'; import { and, eq } from 'drizzle-orm'; -const packItemsRoutes = new OpenAPIHono(); +const packItemsRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Get all items for a pack const getItemsRoute = createRoute({ method: 'get', path: '/{packId}/items', - request: { params: z.object({ packId: z.string() }) }, - responses: { 200: { description: 'Get pack items' } }, + tags: ['Pack Items'], + summary: 'Get pack items', + description: + 'Retrieve all items in a pack. Users can access items from their own packs or public packs.', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + }, + responses: { + 200: { + description: 'Pack items retrieved successfully', + content: { + 'application/json': { + schema: z.array(PackItemSchema), + }, + }, + }, + 403: { + description: 'Forbidden - pack is private', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Pack not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packItemsRoutes.openapi(getItemsRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); const packId = c.req.param('packId'); @@ -62,21 +103,83 @@ packItemsRoutes.openapi(getItemsRoute, async (c) => { }, }); - return c.json(items); + // Map items to ensure consumable, worn, and deleted are not null + const mappedItems = items.map((item) => ({ + ...item, + consumable: item.consumable ?? false, + worn: item.worn ?? false, + deleted: item.deleted ?? false, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + })); + + return c.json(mappedItems, 200); }); // Get pack item by ID const getItemRoute = createRoute({ method: 'get', path: '/items/{itemId}', - request: { params: z.object({ itemId: z.string() }) }, - responses: { 200: { description: 'Get pack item' } }, + tags: ['Pack Items'], + summary: 'Get pack item by ID', + description: + 'Retrieve a specific pack item by its ID. Users can access items from their own packs or public packs.', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + itemId: z.string().openapi({ example: 'pi_123456' }), + }), + }, + responses: { + 200: { + description: 'Pack item retrieved successfully', + content: { + 'application/json': { + schema: PackItemSchema.extend({ + catalogItem: z + .object({ + id: z.string(), + name: z.string(), + brand: z.string().nullable(), + category: z.string().nullable(), + description: z.string().nullable(), + price: z.number().nullable(), + weight: z.number().nullable(), + image: z.string().nullable(), + }) + .nullable(), + pack: z + .object({ + id: z.string(), + name: z.string(), + isPublic: z.boolean(), + }) + .nullable(), + }), + }, + }, + }, + 403: { + description: 'Forbidden - item is private', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packItemsRoutes.openapi(getItemRoute, async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const userId = auth.userId; @@ -101,25 +204,88 @@ packItemsRoutes.openapi(getItemRoute, async (c) => { return c.json({ error: 'Unauthorized' }, { status: 403 }); } - return c.json(item); + // Map item to ensure nullable fields are handled + const mappedItem = { + ...item, + consumable: item.consumable ?? false, + worn: item.worn ?? false, + deleted: item.deleted ?? false, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + pack: item.pack + ? { + ...item.pack, + isPublic: item.pack.isPublic ?? false, + } + : null, + }; + + return c.json(mappedItem, 200); }); // Add an item to a pack const addItemRoute = createRoute({ method: 'post', path: '/{packId}/items', + tags: ['Pack Items'], + summary: 'Add item to pack', + description: 'Add a new item to a pack with automatic embedding generation for AI features', + security: [{ bearerAuth: [] }], request: { - params: z.object({ packId: z.string() }), - body: { content: { 'application/json': { schema: z.any() } } }, + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + body: { + content: { + 'application/json': { + schema: CreatePackItemRequestSchema.extend({ + id: z + .string() + .openapi({ example: 'pi_123456', description: 'Client-generated item ID' }), + catalogItemId: z + .number() + .optional() + .openapi({ example: 12345, description: 'Reference to catalog item' }), + consumable: z.boolean().optional().default(false), + worn: z.boolean().optional().default(false), + notes: z.string().optional(), + weightUnit: z.string().optional().default('g'), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Item added to pack successfully', + content: { + 'application/json': { + schema: PackItemSchema, + }, + }, + }, + 400: { + description: 'Bad request - missing required fields', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error - embedding generation failed', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Add item to pack' } }, }); packItemsRoutes.openapi(addItemRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); const packId = c.req.param('packId'); @@ -128,7 +294,7 @@ packItemsRoutes.openapi(addItemRoute, async (c) => { getEnv(c); if (!OPENAI_API_KEY) { - return c.json({ error: 'OpenAI API key not configured' }, 500); + return c.json({ error: 'OpenAI API key not configured' }, 400); } if (!packId) { @@ -173,25 +339,81 @@ packItemsRoutes.openapi(addItemRoute, async (c) => { await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); - return c.json(newItem); + if (!newItem) { + return c.json({ error: 'Failed to create item' }, 400); + } + + // Map the new item to ensure proper format + const mappedNewItem = { + ...newItem, + consumable: newItem.consumable ?? false, + worn: newItem.worn ?? false, + deleted: newItem.deleted ?? false, + createdAt: newItem.createdAt.toISOString(), + updatedAt: newItem.updatedAt.toISOString(), + embedding: undefined, // Don't send embedding in response + templateItemId: newItem.templateItemId ?? null, + }; + + return c.json(mappedNewItem, 201); }); // Update a pack item const updateItemRoute = createRoute({ method: 'patch', path: '/items/{itemId}', + tags: ['Pack Items'], + summary: 'Update pack item', + description: 'Update pack item details with automatic embedding regeneration when text changes', + security: [{ bearerAuth: [] }], request: { - params: z.object({ itemId: z.string() }), - body: { content: { 'application/json': { schema: z.any() } } }, + params: z.object({ + itemId: z.string().openapi({ example: 'pi_123456' }), + }), + body: { + content: { + 'application/json': { + schema: UpdatePackItemRequestSchema.extend({ + consumable: z.boolean().optional(), + worn: z.boolean().optional(), + notes: z.string().optional(), + weightUnit: z.string().optional(), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Item updated successfully', + content: { + 'application/json': { + schema: PackItemSchema, + }, + }, + }, + 404: { + description: 'Item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error - embedding generation failed', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Update pack item' } }, }); packItemsRoutes.openapi(updateItemRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); @@ -201,7 +423,7 @@ packItemsRoutes.openapi(updateItemRoute, async (c) => { getEnv(c); if (!OPENAI_API_KEY) { - return c.json({ error: 'OpenAI API key not configured' }, 500); + return c.json({ error: 'OpenAI API key not configured' }, 400); } const existingItem = await db.query.packItems.findFirst({ @@ -285,24 +507,59 @@ packItemsRoutes.openapi(updateItemRoute, async (c) => { // Update the pack's updatedAt timestamp await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, updatedItem.packId)); - return c.json(updatedItem[0]); + // Map the updated item to ensure proper format + const mappedUpdatedItem = { + ...updatedItem, + consumable: updatedItem.consumable ?? false, + worn: updatedItem.worn ?? false, + deleted: updatedItem.deleted ?? false, + createdAt: updatedItem.createdAt.toISOString(), + updatedAt: updatedItem.updatedAt.toISOString(), + embedding: undefined, // Don't send embedding in response + templateItemId: updatedItem.templateItemId ?? null, + }; + + return c.json(mappedUpdatedItem, 200); }); // Delete a pack item const deleteItemRoute = createRoute({ method: 'delete', path: '/items/{itemId}', + tags: ['Pack Items'], + summary: 'Delete pack item', + description: 'Permanently remove an item from a pack', + security: [{ bearerAuth: [] }], request: { - params: z.object({ itemId: z.string() }), + params: z.object({ + itemId: z.string().openapi({ example: 'pi_123456' }), + }), + }, + responses: { + 200: { + description: 'Item deleted successfully', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean().openapi({ example: true }), + itemId: z.string().openapi({ example: 'pi_123456' }), + }), + }, + }, + }, + 404: { + description: 'Item not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Delete pack item' } }, }); packItemsRoutes.openapi(deleteItemRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); @@ -322,7 +579,7 @@ packItemsRoutes.openapi(deleteItemRoute, async (c) => { await db.update(packs).set({ updatedAt: new Date() }).where(eq(packs.id, packId)); - return c.json({ success: true, itemId: itemId }); + return c.json({ success: true, itemId: itemId }, 200); }); export { packItemsRoutes }; diff --git a/packages/api/src/routes/packs/list.ts b/packages/api/src/routes/packs/list.ts index 6e62f9a837..adbf6b3fa4 100644 --- a/packages/api/src/routes/packs/list.ts +++ b/packages/api/src/routes/packs/list.ts @@ -1,26 +1,48 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; -import { packItems, packs, packWeightHistory } from '@packrat/api/db/schema'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { type PackWithItems, packItems, packs, packWeightHistory } from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { CreatePackRequestSchema, PackWithWeightsSchema } from '@packrat/api/schemas/packs'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { computePacksWeights } from '@packrat/api/utils/compute-pack'; import { and, eq, or } from 'drizzle-orm'; -const packsListRoutes = new OpenAPIHono(); +const packsListRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); const listGetRoute = createRoute({ method: 'get', path: '/', + tags: ['Packs'], + summary: 'List user packs', + description: + 'Get all packs belonging to the authenticated user, optionally including public packs', + security: [{ bearerAuth: [] }], request: { query: z.object({ - includePublic: z.coerce.number().int().min(0).max(1).optional().default(0), + includePublic: z.coerce.number().int().min(0).max(1).optional().default(0).openapi({ + example: 0, + description: 'Include public packs from other users (0 = false, 1 = true)', + }), }), }, - responses: { 200: { description: 'Get user packs' } }, + responses: { + 200: { + description: 'Packs retrieved successfully', + content: { + 'application/json': { + schema: z.array(PackWithWeightsSchema), + }, + }, + }, + }, }); packsListRoutes.openapi(listGetRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const { includePublic } = c.req.valid('query'); const includePublicBool = Boolean(includePublic).valueOf(); @@ -37,19 +59,54 @@ packsListRoutes.openapi(listGetRoute, async (c) => { }, }); - return c.json(computePacksWeights(result)); + return c.json(computePacksWeights(result), 200); }); const listPostRoute = createRoute({ method: 'post', path: '/', - request: { body: { content: { 'application/json': { schema: z.any() } } } }, - responses: { 200: { description: 'Create pack' } }, + tags: ['Packs'], + summary: 'Create new pack', + description: 'Create a new pack for the authenticated user', + security: [{ bearerAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: CreatePackRequestSchema.extend({ + id: z + .string() + .openapi({ example: 'p_123456', description: 'Client-generated pack ID' }), + localCreatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + localUpdatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Pack created successfully', + content: { + 'application/json': { + schema: PackWithWeightsSchema, + }, + }, + }, + 400: { + description: 'Bad request - missing pack ID', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packsListRoutes.openapi(listPostRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const data = await c.req.json(); @@ -75,26 +132,63 @@ packsListRoutes.openapi(listPostRoute, async (c) => { }) .returning(); - const packWithWeights = computePacksWeights([{ ...newPack, items: [] }])[0]; - return c.json(packWithWeights); + if (!newPack) { + return c.json({ error: 'Failed to create pack' }, 400); + } + + const packWithItems: PackWithItems = { + ...newPack, + items: [], + }; + + const packWithWeights = computePacksWeights([packWithItems])[0]; + return c.json(packWithWeights, 200); }); const weightHistoryRoute = createRoute({ method: 'get', path: '/weight-history', - responses: { 200: { description: 'Get weight history' } }, + tags: ['Packs'], + summary: 'Get user weight history', + description: 'Retrieve all weight history entries for the authenticated user across all packs', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Weight history retrieved successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.string(), + packId: z.string(), + userId: z.number(), + weight: z.number().openapi({ description: 'Weight in grams' }), + localCreatedAt: z.string().datetime(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), + ), + }, + }, + }, + }, }); packsListRoutes.openapi(weightHistoryRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) return unauthorizedResponse(); + const auth = c.get('user'); const db = createDb(c); const userPackWeightHistories = await db.query.packWeightHistory.findMany({ where: eq(packWeightHistory.userId, auth.userId), }); - return c.json(userPackWeightHistories); + // Add updatedAt field (using createdAt as fallback since table doesn't have updatedAt) + const historiesWithUpdatedAt = userPackWeightHistories.map((history) => ({ + ...history, + updatedAt: history.createdAt, + })); + + return c.json(historiesWithUpdatedAt, 200); }); export { packsListRoutes }; diff --git a/packages/api/src/routes/packs/pack.ts b/packages/api/src/routes/packs/pack.ts index 54818d9f1e..455b68882b 100644 --- a/packages/api/src/routes/packs/pack.ts +++ b/packages/api/src/routes/packs/pack.ts @@ -8,30 +8,65 @@ import { packs, packWeightHistory, } from '@packrat/api/db/schema'; - -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { + ItemSuggestionsRequestSchema, + PackWithWeightsSchema, + UpdatePackRequestSchema, +} from '@packrat/api/schemas/packs'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { computePackWeights } from '@packrat/api/utils/compute-pack'; import { getPackDetails } from '@packrat/api/utils/DbUtils'; import { and, cosineDistance, desc, eq, gt, notInArray, sql } from 'drizzle-orm'; -const packRoutes = new OpenAPIHono(); +const packRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Get a specific pack const getPackRoute = createRoute({ method: 'get', path: '/{packId}', + tags: ['Packs'], + summary: 'Get pack by ID', + description: 'Retrieve a specific pack by its ID with all items', + security: [{ bearerAuth: [] }], request: { - params: z.object({ packId: z.string() }), + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + }, + responses: { + 200: { + description: 'Pack retrieved successfully', + content: { + 'application/json': { + schema: PackWithWeightsSchema, + }, + }, + }, + 404: { + description: 'Pack not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Get pack' } }, }); packRoutes.openapi(getPackRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); try { const packId = c.req.param('packId'); @@ -47,7 +82,7 @@ packRoutes.openapi(getPackRoute, async (c) => { if (!pack) { return c.json({ error: 'Pack not found' }, 404); } - return c.json(pack); + return c.json(pack, 200); } catch (error) { console.error('Error fetching pack:', error); return c.json({ error: 'Failed to fetch pack' }, 500); @@ -58,20 +93,53 @@ packRoutes.openapi(getPackRoute, async (c) => { const updatePackRoute = createRoute({ method: 'put', path: '/{packId}', + tags: ['Packs'], + summary: 'Update pack', + description: 'Update pack information such as name, description, category, and visibility', + security: [{ bearerAuth: [] }], request: { - params: z.object({ packId: z.string() }), + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), body: { - content: { 'application/json': { schema: z.any() } }, + content: { + 'application/json': { + schema: UpdatePackRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Pack updated successfully', + content: { + 'application/json': { + schema: PackWithWeightsSchema, + }, + }, + }, + 404: { + description: 'Pack not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, - responses: { 200: { description: 'Update pack' } }, }); packRoutes.openapi(updatePackRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); try { @@ -109,7 +177,7 @@ packRoutes.openapi(updatePackRoute, async (c) => { } const packWithWeights = computePackWeights(updatedPack); - return c.json(packWithWeights); + return c.json(packWithWeights, 200); } catch (error) { console.error('Error updating pack:', error); return c.json({ error: 'Failed to update pack' }, 500); @@ -120,21 +188,43 @@ packRoutes.openapi(updatePackRoute, async (c) => { const deletePackRoute = createRoute({ method: 'delete', path: '/{packId}', - request: { params: z.object({ packId: z.string() }) }, - responses: { 200: { description: 'Delete pack' } }, + tags: ['Packs'], + summary: 'Delete pack', + description: 'Permanently delete a pack and all its items', + security: [{ bearerAuth: [] }], + request: { + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + }, + responses: { + 200: { + description: 'Pack deleted successfully', + content: { + 'application/json': { + schema: z.object({ + success: z.boolean().openapi({ example: true }), + }), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); packRoutes.openapi(deletePackRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); try { const packId = c.req.param('packId'); await db.delete(packs).where(eq(packs.id, packId)); - return c.json({ success: true }); + return c.json({ success: true }, 200); } catch (error) { console.error('Error deleting pack:', error); return c.json({ error: 'Failed to delete pack' }, 500); @@ -144,19 +234,61 @@ packRoutes.openapi(deletePackRoute, async (c) => { const itemSuggestionsRoute = createRoute({ method: 'post', path: '/{packId}/item-suggestions', + tags: ['Packs'], + summary: 'Get item suggestions for pack', + description: + 'Get AI-powered item suggestions based on existing pack items using similarity matching', + security: [{ bearerAuth: [] }], request: { - params: z.object({ packId: z.string() }), - body: { content: { 'application/json': { schema: z.any() } } }, + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + body: { + content: { + 'application/json': { + schema: ItemSuggestionsRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Item suggestions retrieved successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.string(), + name: z.string(), + image: z.string().nullable(), + category: z.string().nullable(), + similarity: z.number(), + }), + ), + }, + }, + }, + 400: { + description: 'Bad request - no embeddings found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'Pack not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Pack item suggestions' } }, }); packRoutes.openapi(itemSuggestionsRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const db = createDb(c); const packId = c.req.param('packId'); @@ -174,8 +306,14 @@ packRoutes.openapi(itemSuggestionsRoute, async (c) => { return c.json({ error: 'No embeddings found for existing items' }, 400); } - const avgEmbedding = existingEmbeddings[0].map( - (_, i) => existingEmbeddings.reduce((sum, emb) => sum + emb[i], 0) / existingEmbeddings.length, + const firstEmbedding = existingEmbeddings[0]; + if (!firstEmbedding) { + return c.json({ error: 'No valid embeddings found' }, 400); + } + + const avgEmbedding = firstEmbedding.map( + (_, i) => + existingEmbeddings.reduce((sum, emb) => sum + (emb?.[i] ?? 0), 0) / existingEmbeddings.length, ); const similarity = sql`1 - (${cosineDistance(catalogItems.embedding, avgEmbedding)})`; @@ -202,24 +340,74 @@ packRoutes.openapi(itemSuggestionsRoute, async (c) => { .orderBy(desc(similarity)) .limit(5); - return c.json(similarItems); + // Transform to match expected schema (singular image/category instead of arrays) + const transformedItems = similarItems.map((item) => ({ + id: item.id.toString(), + name: item.name, + image: item.images?.[0] ?? null, + category: item.categories?.[0] ?? null, + similarity: item.similarity, + })); + + return c.json(transformedItems, 200); }); const weightHistoryRoute = createRoute({ method: 'post', path: '/{packId}/weight-history', + tags: ['Packs'], + summary: 'Create pack weight history entry', + description: 'Record a weight history entry for pack tracking over time', + security: [{ bearerAuth: [] }], request: { - params: z.object({ packId: z.string() }), - body: { content: { 'application/json': { schema: z.any() } } }, + params: z.object({ + packId: z.string().openapi({ example: 'p_123456' }), + }), + body: { + content: { + 'application/json': { + schema: z.object({ + id: z.string().openapi({ example: 'pwh_123456' }), + weight: z.number().openapi({ example: 5500, description: 'Weight in grams' }), + localCreatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'Weight history entry created successfully', + content: { + 'application/json': { + schema: z.array( + z.object({ + id: z.string(), + packId: z.string(), + userId: z.number(), + weight: z.number(), + localCreatedAt: z.string().datetime(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }), + ), + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Create pack weight history' } }, }); packRoutes.openapi(weightHistoryRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const db = createDb(c); try { @@ -237,7 +425,13 @@ packRoutes.openapi(weightHistoryRoute, async (c) => { }) .returning(); - return c.json(packWeightHistoryEntry); + // Add updatedAt field (using createdAt as fallback since table doesn't have updatedAt) + const entryWithUpdatedAt = packWeightHistoryEntry.map((entry) => ({ + ...entry, + updatedAt: entry.createdAt, + })); + + return c.json(entryWithUpdatedAt, 200); } catch (error) { console.error('Pack weight history API error:', error); return c.json({ error: 'Failed to create weight history entry' }, 500); diff --git a/packages/api/src/routes/search.ts b/packages/api/src/routes/search.ts index a18d7328ff..0bfee10f6d 100644 --- a/packages/api/src/routes/search.ts +++ b/packages/api/src/routes/search.ts @@ -1,73 +1,95 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; -import type { Env } from '@packrat/api/utils/env-validation'; +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { + ErrorResponseSchema, + VectorSearchQuerySchema, + VectorSearchResponseSchema, +} from '@packrat/api/schemas/search'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { getEnv } from '@packrat/api/utils/env-validation'; import { cosineDistance, desc, gt, sql } from 'drizzle-orm'; import { createDb } from '../db'; import { catalogItems } from '../db/schema'; import { generateEmbedding } from '../services/embeddingService'; -import { authenticateRequest, unauthorizedResponse } from '../utils/api-middleware'; -const searchRoutes = new OpenAPIHono<{ Bindings: Env }>(); +const searchRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); const searchVectorRoute = createRoute({ method: 'get', path: '/vector', + tags: ['Search'], + summary: 'Vector similarity search', + description: 'Search for similar catalog items using AI embeddings and vector similarity', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - q: z.string().min(1), - }), + query: VectorSearchQuerySchema, }, responses: { 200: { - description: 'Search similar catalog items', - }, - 401: { - description: 'Unauthorized', + description: 'List of similar items with similarity scores', + content: { + 'application/json': { + schema: VectorSearchResponseSchema, + }, + }, }, 500: { - description: 'Internal Server Error', + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, }, }, }); searchRoutes.openapi(searchVectorRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + try { + const auth = c.get('user'); + if (!auth) { + return c.json({ error: 'Unauthorized' }, 401); + } - const db = createDb(c); - const { q } = c.req.query(); - const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID, AI } = - getEnv(c); + const db = createDb(c); + const { q } = c.req.query(); + const { OPENAI_API_KEY, AI_PROVIDER, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_AI_GATEWAY_ID, AI } = + getEnv(c); - const embedding = await generateEmbedding({ - value: q, - openAiApiKey: OPENAI_API_KEY, - provider: AI_PROVIDER, - cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, - cloudflareGatewayId: CLOUDFLARE_AI_GATEWAY_ID, - cloudflareAiBinding: AI, - }); + const embedding = await generateEmbedding({ + value: q ?? '', + openAiApiKey: OPENAI_API_KEY, + provider: AI_PROVIDER, + cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, + cloudflareGatewayId: CLOUDFLARE_AI_GATEWAY_ID, + cloudflareAiBinding: AI, + }); - if (!embedding) { - return c.json({ error: 'Failed to generate embedding' }, 500); - } + if (!embedding) { + return c.json({ error: 'Failed to generate embedding', code: 'EMBEDDING_ERROR' }, 500); + } - const similarity = sql`1 - (${cosineDistance(catalogItems.embedding, embedding)})`; + const similarity = sql`1 - (${cosineDistance(catalogItems.embedding, embedding)})`; - const similarItems = await db - .select({ - id: catalogItems.id, - name: catalogItems.name, - similarity, - }) - .from(catalogItems) - .where(gt(similarity, 0.1)) - .orderBy(desc(similarity)) - .limit(10); + const similarItems = await db + .select({ + id: catalogItems.id, + name: catalogItems.name, + similarity, + }) + .from(catalogItems) + .where(gt(similarity, 0.1)) + .orderBy(desc(similarity)) + .limit(10); - return c.json(similarItems); + return c.json(similarItems, 200); + } catch (error) { + console.error('Error performing vector search:', error); + return c.json({ error: 'Internal server error', code: 'SEARCH_ERROR' }, 500); + } }); export { searchRoutes }; diff --git a/packages/api/src/routes/upload.ts b/packages/api/src/routes/upload.ts index ebead510be..8440a2ba8b 100644 --- a/packages/api/src/routes/upload.ts +++ b/packages/api/src/routes/upload.ts @@ -1,8 +1,12 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; -import type { Env } from '@packrat/api/utils/env-validation'; +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { + ErrorResponseSchema, + PresignedUploadQuerySchema, + PresignedUploadResponseSchema, +} from '@packrat/api/schemas/upload'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; import type { Variables } from '../types/variables'; @@ -12,21 +16,51 @@ const uploadRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); const presignedRoute = createRoute({ method: 'get', path: '/presigned', + tags: ['Upload'], + summary: 'Generate presigned upload URL', + description: 'Generate a presigned URL for secure file uploads to R2 storage', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - fileName: z.string().optional(), - contentType: z.string().optional(), - }), + query: PresignedUploadQuerySchema, + }, + responses: { + 200: { + description: 'Presigned URL generated successfully', + content: { + 'application/json': { + schema: PresignedUploadResponseSchema, + }, + }, + }, + 400: { + description: 'Bad request - fileName and contentType are required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 403: { + description: 'Forbidden - File name must start with user ID', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, - responses: { 200: { description: 'Generate presigned upload URL' } }, }); uploadRoutes.openapi(presignedRoute, async (c) => { - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const auth = c.get('user'); const { R2_ACCESS_KEY_ID, @@ -70,9 +104,12 @@ uploadRoutes.openapi(presignedRoute, async (c) => { expiresIn: 3600, }); - return c.json({ - url: presignedUrl, - }); + return c.json( + { + url: presignedUrl, + }, + 200, + ); } catch (error) { c.get('sentry').setContext('upload-params', { fileName: c.req.query('fileName'), diff --git a/packages/api/src/routes/user/index.ts b/packages/api/src/routes/user/index.ts index d70057a46d..538cd3dbe8 100644 --- a/packages/api/src/routes/user/index.ts +++ b/packages/api/src/routes/user/index.ts @@ -1,7 +1,243 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { createDb } from '@packrat/api/db'; +import { users } from '@packrat/api/db/schema'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { + UpdateUserRequestSchema, + UpdateUserResponseSchema, + UserProfileSchema, +} from '@packrat/api/schemas/users'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; +import { eq } from 'drizzle-orm'; import { userItemsRoutes } from './items'; -const userRoutes = new OpenAPIHono(); +const userRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); + +// Get user profile +const getUserProfileRoute = createRoute({ + method: 'get', + path: '/profile', + tags: ['Users'], + summary: 'Get user profile', + description: 'Get the authenticated user profile information', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'User profile retrieved successfully', + content: { + 'application/json': { + schema: UserProfileSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +userRoutes.openapi(getUserProfileRoute, async (c) => { + try { + const auth = c.get('user'); + + const db = createDb(c); + const [user] = await db + .select({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + emailVerified: users.emailVerified, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(users) + .where(eq(users.id, auth.userId)) + .limit(1); + + if (!user) { + return c.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, 404); + } + + return c.json({ + success: true, + user: { + ...user, + createdAt: user.createdAt?.toISOString() || null, + updatedAt: user.updatedAt?.toISOString() || null, + }, + }); + } catch (error) { + console.error('Error fetching user profile:', error); + return c.json( + { + error: 'Failed to fetch user profile', + code: 'FETCH_ERROR', + }, + 500, + ); + } +}); + +// Update user profile +const updateUserProfileRoute = createRoute({ + method: 'put', + path: '/profile', + tags: ['Users'], + summary: 'Update user profile', + description: 'Update the authenticated user profile information', + security: [{ bearerAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: UpdateUserRequestSchema, + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: 'User profile updated successfully', + content: { + 'application/json': { + schema: UpdateUserResponseSchema, + }, + }, + }, + 400: { + description: 'Bad request - Invalid input data', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: 'User not found', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 409: { + description: 'Conflict - Email already in use by another user', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +userRoutes.openapi(updateUserProfileRoute, async (c) => { + try { + const auth = c.get('user'); + + const { firstName, lastName, email } = c.req.valid('json'); + const db = createDb(c); + + // If email is being updated, check if it's already in use + if (email) { + const [existingUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (existingUser && existingUser.id !== auth.userId) { + return c.json( + { error: 'Email already in use by another user', code: 'EMAIL_CONFLICT' }, + 409, + ); + } + } + + // Prepare update data + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (firstName !== undefined) updateData.firstName = firstName; + if (lastName !== undefined) updateData.lastName = lastName; + if (email !== undefined) { + updateData.email = email.toLowerCase(); + updateData.emailVerified = false; // Reset verification if email changes + } + + // Update user + const [updatedUser] = await db + .update(users) + .set(updateData) + .where(eq(users.id, auth.userId)) + .returning({ + id: users.id, + email: users.email, + firstName: users.firstName, + lastName: users.lastName, + role: users.role, + emailVerified: users.emailVerified, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }); + + if (!updatedUser) { + return c.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, 404); + } + + const message = email + ? 'Profile updated successfully. Please verify your new email address.' + : 'Profile updated successfully'; + + return c.json({ + success: true, + message, + user: { + ...updatedUser, + createdAt: updatedUser.createdAt?.toISOString() || null, + updatedAt: updatedUser.updatedAt?.toISOString() || null, + }, + }); + } catch (error) { + console.error('Error updating user profile:', error); + return c.json( + { + error: 'Failed to update user profile', + code: 'UPDATE_ERROR', + }, + 500, + ); + } +}); userRoutes.route('/', userItemsRoutes); diff --git a/packages/api/src/routes/user/items.ts b/packages/api/src/routes/user/items.ts index f9cdb0882c..4da78dc124 100644 --- a/packages/api/src/routes/user/items.ts +++ b/packages/api/src/routes/user/items.ts @@ -1,24 +1,47 @@ import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; import { createDb } from '@packrat/api/db'; import { packItems } from '@packrat/api/db/schema'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { ErrorResponseSchema } from '@packrat/api/schemas/catalog'; +import { UserItemsResponseSchema } from '@packrat/api/schemas/users'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { eq } from 'drizzle-orm'; -const userItemsRoutes = new OpenAPIHono(); +const userItemsRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); // Get all pack items for the authenticated user const userItemsGetRoute = createRoute({ method: 'get', path: '/items', - responses: { 200: { description: "Get user's items" } }, + tags: ['Users'], + summary: 'Get user items', + description: 'Retrieve all pack items belonging to the authenticated user across all their packs', + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'User items retrieved successfully', + content: { + 'application/json': { + schema: UserItemsResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, }); userItemsRoutes.openapi(userItemsGetRoute, async (c) => { - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - + const auth = c.get('user'); const db = createDb(c); const items = await db.query.packItems.findMany({ @@ -28,7 +51,7 @@ userItemsRoutes.openapi(userItemsGetRoute, async (c) => { }, }); - return c.json(items); + return c.json(items, 200); }); export { userItemsRoutes }; diff --git a/packages/api/src/routes/weather.ts b/packages/api/src/routes/weather.ts index 1cd219684c..bc852ea1bc 100644 --- a/packages/api/src/routes/weather.ts +++ b/packages/api/src/routes/weather.ts @@ -1,8 +1,22 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; -import { authenticateRequest, unauthorizedResponse } from '@packrat/api/utils/api-middleware'; +import { createRoute, OpenAPIHono } from '@hono/zod-openapi'; +import { + ErrorResponseSchema, + LocationSearchResponseSchema, + type WeatherAPICurrentResponse, + type WeatherAPIForecastResponse, + type WeatherAPISearchResponse, + WeatherCoordinateQuerySchema, + WeatherForecastSchema, + WeatherSearchQuerySchema, +} from '@packrat/api/schemas/weather'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; import { getEnv } from '@packrat/api/utils/env-validation'; -const weatherRoutes = new OpenAPIHono(); +const weatherRoutes = new OpenAPIHono<{ + Bindings: Env; + Variables: Variables; +}>(); const WEATHER_API_BASE_URL = 'https://api.weatherapi.com/v1'; @@ -10,25 +24,44 @@ const WEATHER_API_BASE_URL = 'https://api.weatherapi.com/v1'; const searchRoute = createRoute({ method: 'get', path: '/search', + tags: ['Weather'], + summary: 'Search locations', + description: 'Search for locations by name to get weather data', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - q: z.string().optional(), - }), + query: WeatherSearchQuerySchema, }, responses: { - 200: { description: 'Search locations' }, + 200: { + description: 'Location search results', + content: { + 'application/json': { + schema: LocationSearchResponseSchema, + }, + }, + }, + 400: { + description: 'Bad request - Query parameter required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, }); weatherRoutes.openapi(searchRoute, async (c) => { const { WEATHER_API_KEY } = getEnv(c); - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const query = c.req.query('q'); if (!query) { @@ -44,35 +77,27 @@ weatherRoutes.openapi(searchRoute, async (c) => { throw new Error(`API error: ${response.status}`); } - const data = await response.json(); + const data: WeatherAPISearchResponse = await response.json(); // Transform API response to our LocationSearchResult type - const locations = data.map( - (item: { - id: string; - lat: string; - lon: string; - name: string; - region: string; - country: string; - }) => ({ - id: `${item.id || item.lat}_${item.lon}`, - name: item.name, - region: item.region, - country: item.country, - lat: item.lat, - lon: item.lon, - }), - ); - - return c.json(locations); + const locations = data.map((item) => ({ + id: item.id, + name: item.name, + region: item.region, + country: item.country, + lat: typeof item.lat === 'string' ? Number.parseFloat(item.lat) : item.lat, + lon: typeof item.lon === 'string' ? Number.parseFloat(item.lon) : item.lon, + })); + + return c.json(locations, 200); } catch (error) { + console.error('Error searching weather locations:', error); c.get('sentry').setContext('params', { query, weatherApiUrl: WEATHER_API_BASE_URL, weatherApiKey: !!WEATHER_API_KEY, }); - throw error; + return c.json({ error: 'Internal server error', code: 'WEATHER_SEARCH_ERROR' }, 500); } }); @@ -80,26 +105,44 @@ weatherRoutes.openapi(searchRoute, async (c) => { const searchByCoordRoute = createRoute({ method: 'get', path: '/search-by-coordinates', + tags: ['Weather'], + summary: 'Search locations by coordinates', + description: 'Find location information using latitude and longitude coordinates', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - lat: z.string().optional(), - lon: z.string().optional(), - }), + query: WeatherCoordinateQuerySchema, }, responses: { - 200: { description: 'Search locations by coordinates' }, + 200: { + description: 'Location search results by coordinates', + content: { + 'application/json': { + schema: LocationSearchResponseSchema, + }, + }, + }, + 400: { + description: 'Bad request - Valid latitude and longitude required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, }); weatherRoutes.openapi(searchByCoordRoute, async (c) => { const { WEATHER_API_KEY } = getEnv(c); - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } - const latitude = Number.parseFloat(c.req.query('lat') || ''); const longitude = Number.parseFloat(c.req.query('lon') || ''); @@ -120,7 +163,7 @@ weatherRoutes.openapi(searchByCoordRoute, async (c) => { throw new Error(`API error: ${response.status}`); } - const data = await response.json(); + const data: WeatherAPISearchResponse = await response.json(); // If no results, try a reverse geocoding approach with current conditions API if (!data || data.length === 0) { @@ -132,51 +175,43 @@ weatherRoutes.openapi(searchByCoordRoute, async (c) => { throw new Error(`API error: ${currentResponse.status}`); } - const currentData = await currentResponse.json(); + const currentData: WeatherAPICurrentResponse = await currentResponse.json(); if (currentData?.location) { // Create a single result from the current conditions response return c.json([ { - id: `${currentData.location.lat}_${currentData.location.lon}`, + id: currentData.location.id, name: currentData.location.name, region: currentData.location.region, country: currentData.location.country, - lat: Number.parseFloat(currentData.location.lat), - lon: Number.parseFloat(currentData.location.lon), + lat: Number.parseFloat(String(currentData.location.lat)), + lon: Number.parseFloat(String(currentData.location.lon)), }, ]); } } // Transform API response to our LocationSearchResult type - const locations = data.map( - (item: { - id: string; - lat: string; - lon: string; - name: string; - region: string; - country: string; - }) => ({ - id: `${item.id || item.lat}_${item.lon}`, - name: item.name, - region: item.region, - country: item.country, - lat: Number.parseFloat(item.lat), - lon: Number.parseFloat(item.lon), - }), - ); - - return c.json(locations); + const locations = data.map((item) => ({ + id: item.id, + name: item.name, + region: item.region, + country: item.country, + lat: typeof item.lat === 'string' ? Number.parseFloat(item.lat) : item.lat, + lon: typeof item.lon === 'string' ? Number.parseFloat(item.lon) : item.lon, + })); + + return c.json(locations, 200); } catch (error) { + console.error('Error searching weather locations by coordinates:', error); c.get('sentry').setContext('params', { latitude, longitude, weatherApiUrl: WEATHER_API_BASE_URL, weatherApiKey: !!WEATHER_API_KEY, }); - throw error; + return c.json({ error: 'Internal server error', code: 'WEATHER_COORD_SEARCH_ERROR' }, 500); } }); @@ -184,36 +219,54 @@ weatherRoutes.openapi(searchByCoordRoute, async (c) => { const forecastRoute = createRoute({ method: 'get', path: '/forecast', + tags: ['Weather'], + summary: 'Get weather forecast', + description: + 'Retrieve detailed weather forecast data including current conditions, daily forecasts, and alerts', + security: [{ bearerAuth: [] }], request: { - query: z.object({ - lat: z.string().optional(), - lon: z.string().optional(), - }), + query: WeatherCoordinateQuerySchema, }, responses: { - 200: { description: 'Get weather forecast' }, + 200: { + description: 'Weather forecast data', + content: { + 'application/json': { + schema: WeatherForecastSchema, + }, + }, + }, + 400: { + description: 'Bad request - Valid latitude and longitude required', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: 'Internal server error', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, }, }); weatherRoutes.openapi(forecastRoute, async (c) => { const { WEATHER_API_KEY } = getEnv(c); - // Authenticate the request - const auth = await authenticateRequest(c); - if (!auth) { - return unauthorizedResponse(); - } + const idParam = c.req.query('id'); + const id = Number(idParam); - const latitude = Number.parseFloat(c.req.query('lat') || ''); - const longitude = Number.parseFloat(c.req.query('lon') || ''); - - if (Number.isNaN(latitude) || Number.isNaN(longitude)) { - return c.json({ error: 'Valid latitude and longitude parameters are required' }, 400); + if (!idParam || Number.isNaN(id)) { + return c.json({ error: 'Valid location ID is required' }, 400); } try { - // Format coordinates for the API query - const query = `${latitude.toFixed(6)},${longitude.toFixed(6)}`; + const query = `id:${id}`; // Get forecast data with all the details we need const response = await fetch( @@ -224,16 +277,24 @@ weatherRoutes.openapi(forecastRoute, async (c) => { throw new Error(`API error: ${response.status}`); } - const data = await response.json(); - return c.json(data); + const data: WeatherAPIForecastResponse = await response.json(); + const result = { + ...data, + location: { + ...data.location, + id: Number(id), + }, + }; + + return c.json(result, 200); } catch (error) { + console.error('Error fetching weather forecast:', error); c.get('sentry').setContext('params', { - latitude, - longitude, + id, weatherApiUrl: WEATHER_API_BASE_URL, weatherApiKey: !!WEATHER_API_KEY, }); - throw error; + return c.json({ error: 'Internal server error', code: 'WEATHER_FORECAST_ERROR' }, 500); } }); diff --git a/packages/api/src/schemas/auth.ts b/packages/api/src/schemas/auth.ts new file mode 100644 index 0000000000..1cbb70f8db --- /dev/null +++ b/packages/api/src/schemas/auth.ts @@ -0,0 +1,242 @@ +import { z } from '@hono/zod-openapi'; + +export const LoginRequestSchema = z + .object({ + email: z.string().email().openapi({ + example: 'user@example.com', + description: 'User email address', + }), + password: z.string().min(8).openapi({ + example: 'SecurePassword123!', + description: 'User password (minimum 8 characters)', + }), + }) + .openapi('LoginRequest'); + +export const LoginResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + accessToken: z.string().openapi({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'JWT access token', + }), + refreshToken: z.string().openapi({ + example: 'rf_1234567890abcdef', + description: 'Refresh token for obtaining new access tokens', + }), + user: z.object({ + id: z.number().openapi({ example: 1 }), + email: z.string().email().openapi({ example: 'user@example.com' }), + firstName: z.string().nullable().openapi({ example: 'John' }), + lastName: z.string().nullable().openapi({ example: 'Doe' }), + emailVerified: z.boolean().nullable().openapi({ example: true }), + }), + }) + .openapi('LoginResponse'); + +export const RegisterRequestSchema = z + .object({ + email: z.string().email().openapi({ + example: 'newuser@example.com', + description: 'Email address for the new account', + }), + password: z.string().min(8).openapi({ + example: 'SecurePassword123!', + description: 'Password must be at least 8 characters', + }), + firstName: z.string().optional().openapi({ + example: 'Jane', + description: 'User first name', + }), + lastName: z.string().optional().openapi({ + example: 'Smith', + description: 'User last name', + }), + }) + .openapi('RegisterRequest'); + +export const RegisterResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + message: z.string().openapi({ + example: 'User registered successfully. Please check your email for your verification code.', + }), + userId: z.number().openapi({ example: 123 }), + }) + .openapi('RegisterResponse'); + +export const VerifyEmailRequestSchema = z + .object({ + email: z.string().email().openapi({ + example: 'user@example.com', + description: 'Email address to verify', + }), + code: z.string().length(5).openapi({ + example: 'A1B2C', + description: '5-character verification code sent to email', + }), + }) + .openapi('VerifyEmailRequest'); + +export const VerifyEmailResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + message: z.string().openapi({ example: 'Email verified successfully' }), + accessToken: z.string().openapi({ + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + description: 'JWT access token', + }), + refreshToken: z.string().openapi({ + example: 'rf_1234567890abcdef', + description: 'Refresh token', + }), + user: z.object({ + id: z.number().openapi({ example: 1 }), + email: z.string().email().openapi({ example: 'user@example.com' }), + firstName: z.string().nullable().openapi({ example: 'John' }), + lastName: z.string().nullable().openapi({ example: 'Doe' }), + emailVerified: z.boolean().nullable().openapi({ example: true }), + }), + }) + .openapi('VerifyEmailResponse'); + +export const RefreshTokenRequestSchema = z + .object({ + refreshToken: z.string().openapi({ + example: 'rf_1234567890abcdef', + description: 'Valid refresh token', + }), + }) + .openapi('RefreshTokenRequest'); + +export const RefreshTokenResponseSchema = z + .object({ + success: z.boolean(), + accessToken: z.string(), + refreshToken: z.string(), + user: z.object({ + id: z.number(), + email: z.string().email(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + emailVerified: z.boolean().nullable(), + role: z.string().nullable(), + }), + }) + .openapi('RefreshTokenResponse'); + +export const ForgotPasswordRequestSchema = z + .object({ + email: z.string().email().openapi({ + example: 'user@example.com', + description: 'Email address associated with the account', + }), + }) + .openapi('ForgotPasswordRequest'); + +export const ForgotPasswordResponseSchema = z + .object({ + success: z.boolean(), + message: z.string().openapi({ + example: 'If your email is registered, you will receive a verification code', + }), + }) + .openapi('ForgotPasswordResponse'); + +export const ResetPasswordRequestSchema = z + .object({ + email: z.string().email(), + code: z.string().length(5).openapi({ + description: 'Verification code sent to email', + }), + newPassword: z.string().min(8).openapi({ + description: 'New password (minimum 8 characters)', + }), + }) + .openapi('ResetPasswordRequest'); + +export const ResetPasswordResponseSchema = z + .object({ + success: z.boolean(), + message: z.string().openapi({ + example: 'Password reset successfully', + }), + }) + .openapi('ResetPasswordResponse'); + +export const GoogleAuthRequestSchema = z + .object({ + idToken: z.string().openapi({ + description: 'Google ID token obtained from Google Sign-In', + }), + }) + .openapi('GoogleAuthRequest'); + +export const AppleAuthRequestSchema = z + .object({ + identityToken: z.string().openapi({ + description: 'Apple identity token from Sign in with Apple', + }), + authorizationCode: z.string().openapi({ + description: 'Apple authorization code', + }), + }) + .openapi('AppleAuthRequest'); + +export const SocialAuthResponseSchema = z + .object({ + success: z.boolean(), + accessToken: z.string(), + refreshToken: z.string(), + user: z.object({ + id: z.number(), + email: z.string().email(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + emailVerified: z.boolean().nullable(), + role: z.string().nullable(), + }), + isNewUser: z.boolean().optional().openapi({ + description: 'Indicates if this is a newly created account', + }), + }) + .openapi('SocialAuthResponse'); + +export const LogoutRequestSchema = z + .object({ + refreshToken: z.string().openapi({ + description: 'Refresh token to revoke', + }), + }) + .openapi('LogoutRequest'); + +export const LogoutResponseSchema = z + .object({ + success: z.boolean(), + message: z.string(), + }) + .openapi('LogoutResponse'); + +export const MeResponseSchema = z + .object({ + success: z.boolean(), + user: z.object({ + id: z.number().openapi({ example: 1 }), + email: z.string().email().openapi({ example: 'user@example.com' }), + firstName: z.string().nullable().openapi({ example: 'John' }), + lastName: z.string().nullable().openapi({ example: 'Doe' }), + emailVerified: z.boolean().nullable().openapi({ example: true }), + }), + }) + .openapi('MeResponse'); + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts new file mode 100644 index 0000000000..ec752b1c79 --- /dev/null +++ b/packages/api/src/schemas/catalog.ts @@ -0,0 +1,336 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const CatalogItemSchema = z + .object({ + id: z.number().int().positive().openapi({ example: 12345 }), + name: z.string().openapi({ example: 'MSR Hubba Hubba NX 2-Person Tent' }), + productUrl: z.string().openapi({ example: 'https://example.com/product/tent' }), + sku: z.string().openapi({ example: 'MSR-123' }), + weight: z.number().openapi({ example: 1720, description: 'Weight in grams' }), + weightUnit: z.string().openapi({ example: 'g' }), + description: z.string().nullable().openapi({ + example: 'Lightweight 2-person backpacking tent with excellent ventilation', + }), + categories: z + .array(z.string()) + .nullable() + .openapi({ example: ['camping', 'backpacking', 'shelter'] }), + images: z + .array(z.string()) + .nullable() + .openapi({ example: ['https://example.com/tent.jpg'] }), + brand: z.string().nullable().openapi({ example: 'MSR' }), + model: z.string().nullable().openapi({ example: 'Hubba Hubba NX' }), + ratingValue: z.number().nullable().openapi({ example: 4.5 }), + color: z.string().nullable().openapi({ example: 'Green' }), + size: z.string().nullable().openapi({ example: '2-Person' }), + price: z.number().nullable().openapi({ example: 449.95 }), + availability: z + .enum(['in_stock', 'out_of_stock', 'preorder']) + .nullable() + .openapi({ example: 'in_stock' }), + seller: z.string().nullable().openapi({ example: 'REI' }), + productSku: z.string().nullable().openapi({ example: 'REI-789' }), + material: z.string().nullable().openapi({ example: 'Nylon' }), + currency: z.string().nullable().openapi({ example: 'USD' }), + condition: z.string().nullable().openapi({ example: 'New' }), + reviewCount: z.number().int().nullable().openapi({ example: 127 }), + variants: z + .array( + z.object({ + attribute: z.string(), + values: z.array(z.string()), + }), + ) + .nullable() + .optional(), + techs: z.record(z.string(), z.string()).nullable().optional(), + links: z + .array( + z.object({ + title: z.string(), + url: z.string(), + }), + ) + .nullable() + .optional(), + reviews: z + .array( + z.object({ + user_name: z.string(), + user_avatar: z.string().nullable().optional(), + context: z.record(z.string(), z.string()).nullable().optional(), + recommends: z.boolean().nullable().optional(), + rating: z.number(), + title: z.string(), + text: z.string(), + date: z.string(), + images: z.array(z.string()).nullable().optional(), + upvotes: z.number().nullable().optional(), + downvotes: z.number().nullable().optional(), + verified: z.boolean().nullable().optional(), + }), + ) + .nullable() + .optional(), + qas: z + .array( + z.object({ + question: z.string(), + user: z.string().nullable().optional(), + date: z.string(), + answers: z.array( + z.object({ + a: z.string(), + date: z.string(), + user: z.string().nullable().optional(), + upvotes: z.number().nullable().optional(), + }), + ), + }), + ) + .nullable() + .optional(), + faqs: z + .array( + z.object({ + question: z.string(), + answer: z.string(), + }), + ) + .nullable() + .optional(), + createdAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + updatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + }) + .openapi('CatalogItem'); + +export const CatalogItemsQuerySchema = z + .object({ + page: z.coerce.number().int().positive().optional().default(1).openapi({ + example: 1, + description: 'Page number for pagination', + }), + limit: z.coerce.number().int().min(1).max(100).optional().default(20).openapi({ + example: 20, + description: 'Number of items per page', + }), + q: z.string().optional().openapi({ + example: 'tent', + description: 'Search query string', + }), + category: z.string().optional().openapi({ + example: 'Tents', + description: 'Filter by category', + }), + sort: z + .object({ + field: z.enum(['name', 'brand', 'price', 'ratingValue', 'createdAt', 'updatedAt']).openapi({ + example: 'price', + description: 'Field to sort by', + }), + order: z.enum(['asc', 'desc']).openapi({ + example: 'asc', + description: 'Sort order', + }), + }) + .optional(), + }) + .openapi('CatalogItemsQuery'); + +export const CatalogItemsResponseSchema = z + .object({ + items: z.array(CatalogItemSchema), + totalCount: z.number().openapi({ example: 150 }), + page: z.number().openapi({ example: 1 }), + limit: z.number().openapi({ example: 20 }), + totalPages: z.number().openapi({ example: 8 }), + }) + .openapi('CatalogItemsResponse'); + +export const CreateCatalogItemRequestSchema = z + .object({ + name: z.string().min(1).max(255).openapi({ example: 'MSR Hubba Hubba NX 2-Person Tent' }), + productUrl: z.string().url().openapi({ example: 'https://example.com/product/tent' }), + sku: z.string().openapi({ example: 'MSR-123' }), + weight: z.number().openapi({ example: 1720 }), + weightUnit: z.string().openapi({ example: 'g' }), + description: z.string().optional(), + categories: z.array(z.string()).optional(), + images: z.array(z.string()).optional(), + brand: z.string().optional(), + model: z.string().optional(), + ratingValue: z.number().min(0).max(5).optional(), + color: z.string().optional(), + size: z.string().optional(), + price: z.number().optional(), + availability: z.enum(['in_stock', 'out_of_stock', 'preorder']).optional(), + seller: z.string().optional(), + productSku: z.string().optional(), + material: z.string().optional(), + currency: z.string().optional(), + condition: z.string().optional(), + reviewCount: z.number().min(0).optional(), + variants: z + .array( + z.object({ + attribute: z.string(), + values: z.array(z.string()), + }), + ) + .optional(), + techs: z.record(z.string(), z.string()).optional(), + links: z + .array( + z.object({ + title: z.string(), + url: z.string(), + }), + ) + .optional(), + reviews: z + .array( + z.object({ + user_name: z.string(), + user_avatar: z.string().optional(), + context: z.record(z.string(), z.string()).optional(), + recommends: z.boolean().optional(), + rating: z.number(), + title: z.string(), + text: z.string(), + date: z.string(), + images: z.array(z.string()).optional(), + upvotes: z.number().optional(), + downvotes: z.number().optional(), + verified: z.boolean().optional(), + }), + ) + .optional(), + qas: z + .array( + z.object({ + question: z.string(), + user: z.string().optional(), + date: z.string(), + answers: z.array( + z.object({ + a: z.string(), + date: z.string(), + user: z.string().optional(), + upvotes: z.number().optional(), + }), + ), + }), + ) + .optional(), + faqs: z + .array( + z.object({ + question: z.string(), + answer: z.string(), + }), + ) + .optional(), + }) + .openapi('CreateCatalogItemRequest'); + +export const UpdateCatalogItemRequestSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + productUrl: z.string().url().optional(), + sku: z.string().optional(), + weight: z.number().optional(), + weightUnit: z.string().optional(), + description: z.string().optional(), + categories: z.array(z.string()).optional(), + images: z.array(z.string()).optional(), + brand: z.string().optional(), + model: z.string().optional(), + ratingValue: z.number().min(0).max(5).optional(), + color: z.string().optional(), + size: z.string().optional(), + price: z.number().optional(), + availability: z.enum(['in_stock', 'out_of_stock', 'preorder']).optional(), + seller: z.string().optional(), + productSku: z.string().optional(), + material: z.string().optional(), + currency: z.string().optional(), + condition: z.string().optional(), + reviewCount: z.number().min(0).optional(), + variants: z + .array( + z.object({ + attribute: z.string(), + values: z.array(z.string()), + }), + ) + .optional(), + techs: z.record(z.string(), z.string()).optional(), + links: z + .array( + z.object({ + title: z.string(), + url: z.string(), + }), + ) + .optional(), + reviews: z + .array( + z.object({ + user_name: z.string(), + user_avatar: z.string().optional(), + context: z.record(z.string(), z.string()).optional(), + recommends: z.boolean().optional(), + rating: z.number(), + title: z.string(), + text: z.string(), + date: z.string(), + images: z.array(z.string()).optional(), + upvotes: z.number().optional(), + downvotes: z.number().optional(), + verified: z.boolean().optional(), + }), + ) + .optional(), + qas: z + .array( + z.object({ + question: z.string(), + user: z.string().optional(), + date: z.string(), + answers: z.array( + z.object({ + a: z.string(), + date: z.string(), + user: z.string().optional(), + upvotes: z.number().optional(), + }), + ), + }), + ) + .optional(), + faqs: z + .array( + z.object({ + question: z.string(), + answer: z.string(), + }), + ) + .optional(), + }) + .openapi('UpdateCatalogItemRequest'); + +export const CatalogCategoriesResponseSchema = z + .array(z.string().openapi({ example: 'Tents' })) + .openapi('CatalogCategoriesResponse'); diff --git a/packages/api/src/schemas/chat.ts b/packages/api/src/schemas/chat.ts new file mode 100644 index 0000000000..e8a9235bf6 --- /dev/null +++ b/packages/api/src/schemas/chat.ts @@ -0,0 +1,169 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const SuccessResponseSchema = z + .object({ + success: z.boolean().openapi({ + example: true, + description: 'Indicates if the operation was successful', + }), + }) + .openapi('SuccessResponse'); + +export const ChatMessageSchema = z + .object({ + role: z.enum(['user', 'assistant', 'system']).openapi({ + example: 'user', + description: 'The role of the message sender', + }), + content: z.string().openapi({ + example: 'What should I pack for a 3-day hiking trip?', + description: 'The message content', + }), + }) + .openapi('ChatMessage'); + +export const ChatRequestSchema = z.any().openapi('ChatRequest'); +// .oRbject({ +// messages: z.array(ChatMessageSchema).openapi({ +// description: 'Array of chat messages', +// }), +// contextType: z.string().optional().openapi({ +// example: 'pack', +// description: 'Type of context for the chat (e.g., pack, item)', +// }), +// itemId: z.string().optional().openapi({ +// example: 'item_123456', +// description: 'ID of the item being discussed', +// }), +// packId: z.string().optional().openapi({ +// example: 'pack_123456', +// description: 'ID of the pack being discussed', +// }), +// location: z.string().optional().openapi({ +// example: 'Mount Washington, New Hampshire', +// description: 'Current location context for the user', +// }), +// }) +// .openapi('Chatequest'); + +export const ReportedContentUserSchema = z + .object({ + id: z.number().openapi({ + example: 123, + description: 'User ID', + }), + email: z.string().email().openapi({ + example: 'user@example.com', + description: 'User email', + }), + firstName: z.string().nullable().openapi({ + example: 'John', + description: 'User first name', + }), + lastName: z.string().nullable().openapi({ + example: 'Doe', + description: 'User last name', + }), + }) + .openapi('ReportedContentUser'); + +export const ReportedContentSchema = z + .object({ + id: z.number().openapi({ + example: 1, + description: 'Report ID', + }), + userId: z.number().openapi({ + example: 123, + description: 'ID of user who reported', + }), + userQuery: z.string().openapi({ + example: 'What should I pack for winter hiking?', + description: 'The original user query', + }), + aiResponse: z.string().openapi({ + example: 'Here are some essential items for winter hiking...', + description: 'The AI response that was reported', + }), + reason: z.string().openapi({ + example: 'inappropriate_content', + description: 'Reason for reporting', + }), + userComment: z.string().nullable().openapi({ + example: 'The response contained unsafe advice.', + description: 'Additional user comment about the report', + }), + status: z.string().openapi({ + example: 'pending', + description: 'Status of the report', + }), + reviewed: z.boolean().nullable().openapi({ + example: false, + description: 'Whether the report has been reviewed', + }), + reviewedBy: z.number().nullable().openapi({ + example: 456, + description: 'ID of admin who reviewed', + }), + reviewedAt: z.string().datetime().nullable().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the report was reviewed', + }), + createdAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the report was created', + }), + updatedAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the report was last updated', + }), + user: ReportedContentUserSchema, + }) + .openapi('ReportedContent'); + +export const CreateReportRequestSchema = z + .object({ + userQuery: z.string().openapi({ + example: 'What should I pack for winter hiking?', + description: 'The original user query', + }), + aiResponse: z.string().openapi({ + example: 'Here are some essential items for winter hiking...', + description: 'The AI response being reported', + }), + reason: z.string().openapi({ + example: 'inappropriate_content', + description: 'Reason for reporting the content', + }), + userComment: z.string().optional().openapi({ + example: 'The response contained unsafe advice.', + description: 'Additional user comment about the report', + }), + }) + .openapi('CreateReportRequest'); + +export const ReportsResponseSchema = z + .object({ + reportedItems: z.array(ReportedContentSchema), + }) + .openapi('ReportsResponse'); + +export const UpdateReportStatusRequestSchema = z + .object({ + status: z.string().openapi({ + example: 'resolved', + description: 'New status for the report', + }), + }) + .openapi('UpdateReportStatusRequest'); diff --git a/packages/api/src/schemas/guides.ts b/packages/api/src/schemas/guides.ts new file mode 100644 index 0000000000..34160f1296 --- /dev/null +++ b/packages/api/src/schemas/guides.ts @@ -0,0 +1,174 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const GuideSchema = z + .object({ + id: z.string().openapi({ + example: 'ultralight-backpacking', + description: 'Unique identifier for the guide', + }), + key: z.string().openapi({ + example: 'ultralight-backpacking.mdx', + description: 'Storage key/filename for the guide', + }), + title: z.string().openapi({ + example: 'Ultimate Guide to Ultralight Backpacking', + description: 'Guide title', + }), + category: z.string().openapi({ + example: 'backpacking', + description: 'Guide category', + }), + categories: z + .array(z.string()) + .optional() + .openapi({ + example: ['backpacking', 'gear', 'ultralight'], + description: 'Array of categories/tags for the guide', + }), + description: z.string().openapi({ + example: 'Learn the principles of ultralight backpacking and how to reduce your pack weight', + description: 'Brief description of the guide', + }), + author: z.string().optional().openapi({ + example: 'John Doe', + description: 'Guide author name', + }), + readingTime: z.number().optional().openapi({ + example: 15, + description: 'Estimated reading time in minutes', + }), + difficulty: z.string().optional().openapi({ + example: 'intermediate', + description: 'Difficulty level of the guide', + }), + content: z.string().optional().openapi({ + description: 'Full content of the guide (only included in single guide responses)', + }), + createdAt: z.string().datetime().openapi({ + example: '2024-01-01T00:00:00Z', + description: 'When the guide was created', + }), + updatedAt: z.string().datetime().openapi({ + example: '2024-01-01T00:00:00Z', + description: 'When the guide was last updated', + }), + }) + .openapi('Guide'); + +export const GuideDetailSchema = GuideSchema.extend({ + content: z.string().openapi({ + description: 'Full markdown/MDX content of the guide', + }), +}).openapi('GuideDetail'); + +export const GuidesQuerySchema = z + .object({ + page: z.coerce.number().int().positive().optional().default(1).openapi({ + example: 1, + description: 'Page number for pagination', + }), + limit: z.coerce.number().int().positive().optional().default(20).openapi({ + example: 20, + description: 'Number of guides per page', + }), + category: z.string().optional().openapi({ + example: 'backpacking', + description: 'Filter guides by category', + }), + sort: z + .object({ + field: z.enum(['title', 'category', 'createdAt', 'updatedAt']).openapi({ + example: 'title', + description: 'Field to sort by', + }), + order: z.enum(['asc', 'desc']).openapi({ + example: 'asc', + description: 'Sort order', + }), + }) + .optional() + .openapi({ + description: 'Sort parameters', + }), + }) + .openapi('GuidesQuery'); + +export const GuidesResponseSchema = z + .object({ + items: z.array(GuideSchema), + totalCount: z.number().openapi({ + example: 45, + description: 'Total number of guides', + }), + page: z.number().openapi({ + example: 1, + description: 'Current page number', + }), + limit: z.number().openapi({ + example: 20, + description: 'Number of items per page', + }), + totalPages: z.number().openapi({ + example: 3, + description: 'Total number of pages', + }), + }) + .openapi('GuidesResponse'); + +export const GuideSearchQuerySchema = z + .object({ + q: z.string().min(1).openapi({ + example: 'ultralight', + description: 'Search query string', + }), + page: z.coerce.number().int().positive().optional().default(1).openapi({ + example: 1, + description: 'Page number for pagination', + }), + limit: z.coerce.number().int().positive().optional().default(20).openapi({ + example: 20, + description: 'Number of results per page', + }), + category: z.string().optional().openapi({ + example: 'backpacking', + description: 'Filter results by category', + }), + }) + .openapi('GuideSearchQuery'); + +export const GuideSearchResponseSchema = z + .object({ + items: z.array(GuideSchema), + totalCount: z.number().openapi({ + example: 8, + description: 'Total number of search results', + }), + page: z.number().openapi({ + example: 1, + description: 'Current page number', + }), + limit: z.number().openapi({ + example: 20, + description: 'Number of items per page', + }), + totalPages: z.number().openapi({ + example: 1, + description: 'Total number of pages', + }), + query: z.string().openapi({ + example: 'ultralight', + description: 'The search query that was performed', + }), + }) + .openapi('GuideSearchResponse'); diff --git a/packages/api/src/schemas/packTemplates.ts b/packages/api/src/schemas/packTemplates.ts new file mode 100644 index 0000000000..68a99d8c19 --- /dev/null +++ b/packages/api/src/schemas/packTemplates.ts @@ -0,0 +1,304 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const PackTemplateSchema = z + .object({ + id: z.string().openapi({ example: 'pt_123456', description: 'Unique template identifier' }), + name: z + .string() + .openapi({ example: 'Weekend Backpacking Template', description: 'Template name' }), + description: z.string().nullable().openapi({ + example: 'Essential gear for a 2-3 day backpacking trip', + description: 'Template description', + }), + category: z.string().openapi({ example: 'Backpacking', description: 'Template category' }), + userId: z + .number() + .openapi({ example: 123, description: 'ID of the user who created this template' }), + image: z.string().nullable().openapi({ + example: 'https://example.com/template-image.jpg', + description: 'Template image URL', + }), + tags: z + .array(z.string()) + .nullable() + .openapi({ + example: ['backpacking', 'weekend', 'hiking'], + description: 'Template tags for categorization', + }), + isAppTemplate: z.boolean().openapi({ + example: false, + description: 'Whether this is an official app template', + }), + deleted: z.boolean().openapi({ + example: false, + description: 'Whether this template is marked as deleted', + }), + localCreatedAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the template was created locally', + }), + localUpdatedAt: z.string().datetime().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the template was last updated locally', + }), + createdAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the template was created on the server', + }), + updatedAt: z.string().datetime().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the template was last updated on the server', + }), + }) + .openapi('PackTemplate'); + +export const PackTemplateItemSchema = z + .object({ + id: z.string().openapi({ example: 'pti_123456', description: 'Unique item identifier' }), + name: z.string().openapi({ example: 'Tent - 2 Person', description: 'Item name' }), + description: z.string().nullable().openapi({ + example: 'Lightweight 2-person backpacking tent', + description: 'Item description', + }), + weight: z.number().openapi({ example: 1.2, description: 'Item weight' }), + weightUnit: z.string().openapi({ example: 'kg', description: 'Weight unit (g, kg, lb, oz)' }), + quantity: z.number().openapi({ example: 1, description: 'Quantity of this item' }), + category: z.string().nullable().openapi({ + example: 'Shelter', + description: 'Item category', + }), + consumable: z.boolean().openapi({ + example: false, + description: 'Whether this item is consumable', + }), + worn: z.boolean().openapi({ + example: false, + description: 'Whether this item is worn (not carried)', + }), + image: z.string().nullable().openapi({ + example: 'https://example.com/tent.jpg', + description: 'Item image URL', + }), + notes: z.string().nullable().openapi({ + example: 'Great for summer conditions', + description: 'Additional notes about the item', + }), + packTemplateId: z.string().openapi({ + example: 'pt_123456', + description: 'ID of the template this item belongs to', + }), + catalogItemId: z.number().nullable().openapi({ + example: 789, + description: 'ID of the associated catalog item', + }), + userId: z.number().openapi({ example: 123, description: 'ID of the user who added this item' }), + deleted: z.boolean().openapi({ + example: false, + description: 'Whether this item is marked as deleted', + }), + createdAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the item was created', + }), + updatedAt: z.string().datetime().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the item was last updated', + }), + }) + .openapi('PackTemplateItem'); + +export const PackTemplateWithItemsSchema = PackTemplateSchema.extend({ + items: z.array(PackTemplateItemSchema).openapi({ + description: 'List of items in this template', + }), +}).openapi('PackTemplateWithItems'); + +export const CreatePackTemplateRequestSchema = z + .object({ + id: z.string().openapi({ example: 'pt_123456', description: 'Unique template identifier' }), + name: z.string().min(1).max(255).openapi({ + example: 'Weekend Backpacking Template', + description: 'Template name', + }), + description: z.string().optional().openapi({ + example: 'Essential gear for a 2-3 day backpacking trip', + description: 'Template description', + }), + category: z.string().min(1).openapi({ + example: 'Backpacking', + description: 'Template category', + }), + image: z.string().url().optional().openapi({ + example: 'https://example.com/template-image.jpg', + description: 'Template image URL', + }), + tags: z + .array(z.string()) + .optional() + .openapi({ + example: ['backpacking', 'weekend', 'hiking'], + description: 'Template tags for categorization', + }), + isAppTemplate: z.boolean().optional().openapi({ + example: false, + description: 'Whether this is an official app template (admin only)', + }), + localCreatedAt: z.string().datetime().openapi({ + example: '2024-01-01T10:00:00Z', + description: 'When the template was created locally', + }), + localUpdatedAt: z.string().datetime().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the template was last updated locally', + }), + }) + .openapi('CreatePackTemplateRequest'); + +export const UpdatePackTemplateRequestSchema = z + .object({ + name: z.string().min(1).max(255).optional().openapi({ + example: 'Weekend Backpacking Template', + description: 'Template name', + }), + description: z.string().optional().openapi({ + example: 'Essential gear for a 2-3 day backpacking trip', + description: 'Template description', + }), + category: z.string().min(1).optional().openapi({ + example: 'Backpacking', + description: 'Template category', + }), + image: z.string().url().optional().openapi({ + example: 'https://example.com/template-image.jpg', + description: 'Template image URL', + }), + tags: z + .array(z.string()) + .optional() + .openapi({ + example: ['backpacking', 'weekend', 'hiking'], + description: 'Template tags for categorization', + }), + isAppTemplate: z.boolean().optional().openapi({ + example: false, + description: 'Whether this is an official app template (admin only)', + }), + deleted: z.boolean().optional().openapi({ + example: false, + description: 'Whether this template is marked as deleted', + }), + localUpdatedAt: z.string().datetime().optional().openapi({ + example: '2024-01-01T12:00:00Z', + description: 'When the template was last updated locally', + }), + }) + .openapi('UpdatePackTemplateRequest'); + +export const CreatePackTemplateItemRequestSchema = z + .object({ + id: z.string().openapi({ example: 'pti_123456', description: 'Unique item identifier' }), + name: z.string().min(1).max(255).openapi({ + example: 'Tent - 2 Person', + description: 'Item name', + }), + description: z.string().optional().openapi({ + example: 'Lightweight 2-person backpacking tent', + description: 'Item description', + }), + weight: z.number().min(0).openapi({ example: 1.2, description: 'Item weight' }), + weightUnit: z.enum(['g', 'kg', 'lb', 'oz']).openapi({ + example: 'kg', + description: 'Weight unit', + }), + quantity: z.number().int().min(1).optional().default(1).openapi({ + example: 1, + description: 'Quantity of this item', + }), + category: z.string().optional().openapi({ + example: 'Shelter', + description: 'Item category', + }), + consumable: z.boolean().optional().default(false).openapi({ + example: false, + description: 'Whether this item is consumable', + }), + worn: z.boolean().optional().default(false).openapi({ + example: false, + description: 'Whether this item is worn (not carried)', + }), + image: z.string().url().optional().openapi({ + example: 'https://example.com/tent.jpg', + description: 'Item image URL', + }), + notes: z.string().optional().openapi({ + example: 'Great for summer conditions', + description: 'Additional notes about the item', + }), + }) + .openapi('CreatePackTemplateItemRequest'); + +export const UpdatePackTemplateItemRequestSchema = z + .object({ + name: z.string().min(1).max(255).optional().openapi({ + example: 'Tent - 2 Person', + description: 'Item name', + }), + description: z.string().optional().openapi({ + example: 'Lightweight 2-person backpacking tent', + description: 'Item description', + }), + weight: z.number().min(0).optional().openapi({ example: 1.2, description: 'Item weight' }), + weightUnit: z.enum(['g', 'kg', 'lb', 'oz']).optional().openapi({ + example: 'kg', + description: 'Weight unit', + }), + quantity: z.number().int().min(1).optional().openapi({ + example: 1, + description: 'Quantity of this item', + }), + category: z.string().optional().openapi({ + example: 'Shelter', + description: 'Item category', + }), + consumable: z.boolean().optional().openapi({ + example: false, + description: 'Whether this item is consumable', + }), + worn: z.boolean().optional().openapi({ + example: false, + description: 'Whether this item is worn (not carried)', + }), + image: z.string().url().optional().openapi({ + example: 'https://example.com/tent.jpg', + description: 'Item image URL', + }), + notes: z.string().optional().openapi({ + example: 'Great for summer conditions', + description: 'Additional notes about the item', + }), + deleted: z.boolean().optional().openapi({ + example: false, + description: 'Whether this item is marked as deleted', + }), + }) + .openapi('UpdatePackTemplateItemRequest'); + +export const SuccessResponseSchema = z + .object({ + success: z.boolean().openapi({ + example: true, + description: 'Indicates if the operation was successful', + }), + }) + .openapi('SuccessResponse'); diff --git a/packages/api/src/schemas/packs.ts b/packages/api/src/schemas/packs.ts new file mode 100644 index 0000000000..43e8f3cc10 --- /dev/null +++ b/packages/api/src/schemas/packs.ts @@ -0,0 +1,155 @@ +import { z } from '@hono/zod-openapi'; + +export const PackItemSchema = z + .object({ + id: z.string().openapi({ example: 'pi_123456' }), + name: z.string().openapi({ example: 'Sleeping Bag' }), + description: z.string().nullable().openapi({ example: 'Down sleeping bag rated to -5Β°C' }), + weight: z.number().openapi({ example: 850, description: 'Weight in grams' }), + weightUnit: z.string().openapi({ example: 'g' }), + quantity: z.number().int().min(1).openapi({ example: 1 }), + category: z.string().nullable().openapi({ example: 'Sleep System' }), + consumable: z.boolean().openapi({ example: false }), + worn: z.boolean().openapi({ example: false }), + image: z.string().nullable().openapi({ example: 'https://example.com/image.jpg' }), + notes: z.string().nullable().openapi({ example: 'Great for cold weather' }), + packId: z.string().openapi({ example: 'p_123456' }), + catalogItemId: z.number().int().nullable().openapi({ example: 12345 }), + userId: z.number().int().openapi({ example: 1 }), + deleted: z.boolean().openapi({ example: false }), + templateItemId: z.string().nullable().openapi({ example: 'pti_123456' }), + createdAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + updatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + }) + .openapi('PackItem'); + +export const PackSchema = z + .object({ + id: z.string().openapi({ example: 'p_123456' }), + userId: z.number().openapi({ example: 1 }), + name: z.string().openapi({ example: 'Weekend Backpacking Trip' }), + description: z + .string() + .nullable() + .openapi({ example: 'Pack for 2-day backpacking trip in the mountains' }), + category: z.string().nullable().openapi({ example: 'Backpacking' }), + isPublic: z.boolean().openapi({ example: false }), + image: z.string().nullable().openapi({ example: 'https://example.com/pack-image.jpg' }), + tags: z + .array(z.string()) + .nullable() + .openapi({ example: ['backpacking', 'summer', 'mountains'] }), + deleted: z.boolean().openapi({ example: false }), + createdAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + updatedAt: z.string().datetime().openapi({ example: '2024-01-01T00:00:00Z' }), + items: z.array(PackItemSchema).optional(), + }) + .openapi('Pack'); + +export const PackWithWeightsSchema = PackSchema.extend({ + totalWeight: z.number().openapi({ example: 5500, description: 'Total pack weight in grams' }), + baseWeight: z + .number() + .openapi({ example: 4000, description: 'Base weight (excluding consumables) in grams' }), + wornWeight: z.number().openapi({ example: 1000, description: 'Weight of worn items in grams' }), + packWeight: z + .number() + .openapi({ example: 4500, description: 'Pack weight (excluding worn items) in grams' }), + foodWeight: z.number().openapi({ example: 500, description: 'Food weight in grams' }), + waterWeight: z.number().openapi({ example: 1000, description: 'Water weight in grams' }), +}).openapi('PackWithWeights'); + +export const CreatePackRequestSchema = z + .object({ + name: z.string().min(1).max(255).openapi({ example: 'Weekend Backpacking Trip' }), + description: z.string().optional().openapi({ example: 'Pack for 2-day backpacking trip' }), + category: z.string().optional().openapi({ example: 'Backpacking' }), + isPublic: z.boolean().optional().default(false).openapi({ example: false }), + image: z.string().url().optional().openapi({ example: 'https://example.com/pack-image.jpg' }), + tags: z + .array(z.string()) + .optional() + .openapi({ example: ['backpacking', 'summer'] }), + }) + .openapi('CreatePackRequest'); + +export const UpdatePackRequestSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + category: z.string().optional(), + isPublic: z.boolean().optional(), + image: z.string().url().optional(), + tags: z.array(z.string()).optional(), + deleted: z.boolean().optional(), + }) + .openapi('UpdatePackRequest'); + +export const CreatePackItemRequestSchema = z + .object({ + name: z.string().min(1).max(255).openapi({ example: 'Sleeping Bag' }), + description: z.string().optional().openapi({ example: 'Down sleeping bag rated to -5Β°C' }), + weight: z.number().openapi({ example: 850, description: 'Weight in grams' }), + weightUnit: z.string().default('g').openapi({ example: 'g' }), + quantity: z.number().int().min(1).default(1).openapi({ example: 1 }), + category: z.string().optional().openapi({ example: 'Sleep System' }), + consumable: z.boolean().optional().default(false).openapi({ example: false }), + worn: z.boolean().optional().default(false).openapi({ example: false }), + image: z.string().url().optional().openapi({ example: 'https://example.com/image.jpg' }), + notes: z.string().optional().openapi({ example: 'Great for cold weather' }), + catalogItemId: z.number().int().optional().openapi({ example: 12345 }), + }) + .openapi('CreatePackItemRequest'); + +export const UpdatePackItemRequestSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + weight: z.number().optional(), + weightUnit: z.string().optional(), + quantity: z.number().int().min(1).optional(), + category: z.string().optional(), + consumable: z.boolean().optional(), + worn: z.boolean().optional(), + image: z.string().url().optional(), + notes: z.string().optional(), + catalogItemId: z.number().int().optional(), + deleted: z.boolean().optional(), + }) + .openapi('UpdatePackItemRequest'); + +export const PackListResponseSchema = z + .object({ + packs: z.array(PackWithWeightsSchema), + total: z.number().openapi({ example: 25 }), + page: z.number().openapi({ example: 1 }), + limit: z.number().openapi({ example: 10 }), + totalPages: z.number().openapi({ example: 3 }), + }) + .openapi('PackListResponse'); + +export const ItemSuggestionsRequestSchema = z + .object({ + packDescription: z.string().openapi({ + example: 'Weekend backpacking trip in summer mountains', + description: 'Description of the pack to get suggestions for', + }), + }) + .openapi('ItemSuggestionsRequest'); + +export const ItemSuggestionsResponseSchema = z + .object({ + suggestions: z.array( + z.object({ + name: z.string(), + category: z.string(), + weight: z.number().optional(), + description: z.string().optional(), + confidence: z.number().min(0).max(1).openapi({ + example: 0.85, + description: 'Confidence score for the suggestion', + }), + }), + ), + }) + .openapi('ItemSuggestionsResponse'); diff --git a/packages/api/src/schemas/search.ts b/packages/api/src/schemas/search.ts new file mode 100644 index 0000000000..1aba5cb3fb --- /dev/null +++ b/packages/api/src/schemas/search.ts @@ -0,0 +1,42 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const VectorSearchQuerySchema = z + .object({ + q: z.string().min(1).openapi({ + example: 'lightweight tent for backpacking', + description: 'Search query string', + }), + }) + .openapi('VectorSearchQuery'); + +export const SimilarItemSchema = z + .object({ + id: z.number().openapi({ + example: 123456, + description: 'Catalog item ID', + }), + name: z.string().openapi({ + example: 'MSR Hubba Hubba NX 2-Person Tent', + description: 'Item name', + }), + similarity: z.number().min(0).max(1).openapi({ + example: 0.85, + description: 'Similarity score between 0 and 1', + }), + }) + .openapi('SimilarItem'); + +export const VectorSearchResponseSchema = z + .array(SimilarItemSchema) + .openapi('VectorSearchResponse'); diff --git a/packages/api/src/schemas/upload.ts b/packages/api/src/schemas/upload.ts new file mode 100644 index 0000000000..957cfe37a3 --- /dev/null +++ b/packages/api/src/schemas/upload.ts @@ -0,0 +1,35 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const PresignedUploadQuerySchema = z + .object({ + fileName: z.string().optional().openapi({ + example: '123-profile-image.jpg', + description: 'Name of the file to upload (should include user ID prefix)', + }), + contentType: z.string().optional().openapi({ + example: 'image/jpeg', + description: 'MIME type of the file', + }), + }) + .openapi('PresignedUploadQuery'); + +export const PresignedUploadResponseSchema = z + .object({ + url: z.string().url().openapi({ + example: + 'https://packrat-bucket.s3.amazonaws.com/uploads/123-profile-image.jpg?AWSAccessKeyId=...', + description: 'Pre-signed URL for uploading the file', + }), + }) + .openapi('PresignedUploadResponse'); diff --git a/packages/api/src/schemas/users.ts b/packages/api/src/schemas/users.ts new file mode 100644 index 0000000000..3ffeece718 --- /dev/null +++ b/packages/api/src/schemas/users.ts @@ -0,0 +1,208 @@ +import { z } from '@hono/zod-openapi'; + +// Base user schema +export const UserSchema = z + .object({ + id: z.number().int().positive().openapi({ + example: 123, + description: 'Unique user identifier', + }), + email: z.string().email().openapi({ + example: 'user@example.com', + description: 'User email address', + }), + firstName: z.string().nullable().openapi({ + example: 'John', + description: 'User first name', + }), + lastName: z.string().nullable().openapi({ + example: 'Doe', + description: 'User last name', + }), + role: z + .string() + .nullable() + .default('USER') + .openapi({ + example: 'USER', + description: 'User role (USER, ADMIN)', + enum: ['USER', 'ADMIN'], + }), + emailVerified: z.boolean().nullable().openapi({ + example: true, + description: 'Whether the user email has been verified', + }), + createdAt: z.string().nullable().openapi({ + example: '2024-01-15T10:30:00Z', + description: 'User account creation timestamp', + }), + updatedAt: z.string().nullable().openapi({ + example: '2024-01-15T10:30:00Z', + description: 'User account last update timestamp', + }), + }) + .openapi('User'); + +// User profile response schema +export const UserProfileSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + user: UserSchema, + }) + .openapi('UserProfile'); + +// Update user request schema +export const UpdateUserRequestSchema = z + .object({ + firstName: z.string().optional().openapi({ + example: 'Jane', + description: 'Updated first name', + }), + lastName: z.string().optional().openapi({ + example: 'Smith', + description: 'Updated last name', + }), + email: z.string().email().optional().openapi({ + example: 'newemail@example.com', + description: 'Updated email address (requires re-verification)', + }), + }) + .openapi('UpdateUserRequest'); + +// Update user response schema +export const UpdateUserResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + message: z.string().openapi({ + example: 'User profile updated successfully', + }), + user: UserSchema, + }) + .openapi('UpdateUserResponse'); + +// User search query schema +export const UserSearchQuerySchema = z + .object({ + q: z.string().optional().openapi({ + example: 'john', + description: 'Search query for user email, first name, or last name', + }), + limit: z.number().int().positive().max(100).default(20).openapi({ + example: 20, + description: 'Maximum number of results to return', + }), + offset: z.number().int().min(0).default(0).openapi({ + example: 0, + description: 'Number of results to skip for pagination', + }), + }) + .openapi('UserSearchQuery'); + +// User list response schema +export const UserListResponseSchema = z + .object({ + success: z.boolean().openapi({ example: true }), + users: z.array(UserSchema), + pagination: z.object({ + total: z.number().int().min(0).openapi({ + example: 150, + description: 'Total number of users matching the search', + }), + limit: z.number().int().positive().openapi({ + example: 20, + description: 'Maximum results per page', + }), + offset: z.number().int().min(0).openapi({ + example: 0, + description: 'Current page offset', + }), + hasMore: z.boolean().openapi({ + example: true, + description: 'Whether there are more results available', + }), + }), + }) + .openapi('UserListResponse'); + +// User items response schema (for pack items belonging to user) +export const UserItemsResponseSchema = z + .array( + z.object({ + id: z.string().openapi({ + example: 'pi_123456', + description: 'Pack item ID', + }), + name: z.string().openapi({ + example: 'Hiking Boots', + description: 'Item name', + }), + quantity: z.number().int().positive().default(1).openapi({ + example: 1, + description: 'Quantity of this item', + }), + weight: z.number().positive().nullable().openapi({ + example: 850, + description: 'Weight of the item in grams', + }), + weightUnit: z.string().default('g').openapi({ + example: 'g', + description: 'Unit of weight measurement', + }), + category: z.string().nullable().openapi({ + example: 'Footwear', + description: 'Item category', + }), + packId: z.string().openapi({ + example: 'p_123456', + description: 'Pack this item belongs to', + }), + catalogItem: z + .object({ + id: z.number().openapi({ example: 12345 }), + name: z.string().openapi({ example: 'Merrell Hiking Boots' }), + brand: z.string().nullable().openapi({ example: 'Merrell' }), + category: z.string().nullable().openapi({ example: 'Footwear' }), + description: z.string().nullable(), + price: z.number().nullable().openapi({ example: 129.99 }), + weight: z.number().nullable().openapi({ example: 850 }), + image: z.string().nullable(), + }) + .nullable() + .openapi({ + description: 'Catalog item details if this item references a catalog item', + }), + createdAt: z.string().openapi({ + example: '2024-01-15T10:30:00Z', + }), + updatedAt: z.string().openapi({ + example: '2024-01-15T10:30:00Z', + }), + }), + ) + .openapi('UserItemsResponse'); + +// Admin user stats schema +export const AdminUserStatsSchema = z + .object({ + totalUsers: z.number().int().min(0).openapi({ + example: 1250, + description: 'Total number of registered users', + }), + verifiedUsers: z.number().int().min(0).openapi({ + example: 1100, + description: 'Number of users with verified emails', + }), + unverifiedUsers: z.number().int().min(0).openapi({ + example: 150, + description: 'Number of users with unverified emails', + }), + adminUsers: z.number().int().min(0).openapi({ + example: 5, + description: 'Number of admin users', + }), + recentSignups: z.number().int().min(0).openapi({ + example: 23, + description: 'New user signups in the last 30 days', + }), + }) + .openapi('AdminUserStats'); diff --git a/packages/api/src/schemas/weather.ts b/packages/api/src/schemas/weather.ts new file mode 100644 index 0000000000..ced246e532 --- /dev/null +++ b/packages/api/src/schemas/weather.ts @@ -0,0 +1,685 @@ +import { z } from '@hono/zod-openapi'; + +export const ErrorResponseSchema = z + .object({ + error: z.string().openapi({ + description: 'Error message', + }), + code: z.string().optional().openapi({ + description: 'Error code for programmatic handling', + }), + }) + .openapi('ErrorResponse'); + +export const LocationSchema = z + .object({ + id: z.string().openapi({ + example: '42.3601_-71.0589', + description: 'Unique identifier for the location', + }), + name: z.string().openapi({ + example: 'Boston', + description: 'Location name', + }), + region: z.string().openapi({ + example: 'Massachusetts', + description: 'State or region name', + }), + country: z.string().openapi({ + example: 'United States of America', + description: 'Country name', + }), + lat: z.number().openapi({ + example: 42.3601, + description: 'Latitude coordinate', + }), + lon: z.number().openapi({ + example: -71.0589, + description: 'Longitude coordinate', + }), + }) + .openapi('Location'); + +// Extended location schema for API responses +export const WeatherAPILocationSchema = z + .object({ + id: z.number(), + name: z.string(), + region: z.string(), + country: z.string(), + lat: z.union([z.string(), z.number()]), + lon: z.union([z.string(), z.number()]), + tz_id: z.string().optional(), + localtime_epoch: z.number().optional(), + localtime: z.string().optional(), + }) + .openapi('WeatherAPILocation'); + +export const WeatherSearchQuerySchema = z + .object({ + q: z.string().optional().openapi({ + example: 'Boston', + description: 'Location search query (city name, coordinates, etc.)', + }), + }) + .openapi('WeatherSearchQuery'); + +export const WeatherCoordinateQuerySchema = z + .object({ + id: z.string().openapi({ + example: '2618724', + description: 'Unique identifier for the location', + }), + }) + .openapi('WeatherCoordinateQuery'); + +export const WeatherConditionSchema = z + .object({ + text: z.string().openapi({ + example: 'Partly cloudy', + description: 'Weather condition description', + }), + icon: z.string().openapi({ + example: '//cdn.weatherapi.com/weather/64x64/day/116.png', + description: 'Weather condition icon URL', + }), + code: z.number().openapi({ + example: 1003, + description: 'Weather condition code', + }), + }) + .openapi('WeatherCondition'); + +// Air quality schema based on actual API response +export const AirQualitySchema = z + .object({ + co: z.number().openapi({ + example: 397.75, + description: 'Carbon monoxide concentration', + }), + no2: z.number().openapi({ + example: 17.205, + description: 'Nitrogen dioxide concentration', + }), + o3: z.number().openapi({ + example: 117, + description: 'Ozone concentration', + }), + so2: z.number().openapi({ + example: 2.96, + description: 'Sulfur dioxide concentration', + }), + pm2_5: z.number().openapi({ + example: 27.75, + description: 'PM2.5 particulate matter concentration', + }), + pm10: z.number().openapi({ + example: 28.49, + description: 'PM10 particulate matter concentration', + }), + 'us-epa-index': z.number().openapi({ + example: 2, + description: 'US EPA air quality index', + }), + 'gb-defra-index': z.number().openapi({ + example: 3, + description: 'GB DEFRA air quality index', + }), + }) + .openapi('AirQuality'); + +export const WeatherCurrentSchema = z + .object({ + last_updated: z.string().openapi({ + example: '2024-01-01 12:00', + description: 'Last updated timestamp', + }), + temp_c: z.number().openapi({ + example: 22.5, + description: 'Temperature in Celsius', + }), + temp_f: z.number().openapi({ + example: 72.5, + description: 'Temperature in Fahrenheit', + }), + condition: WeatherConditionSchema, + wind_mph: z.number().openapi({ + example: 8.5, + description: 'Wind speed in miles per hour', + }), + wind_kph: z.number().openapi({ + example: 13.7, + description: 'Wind speed in kilometers per hour', + }), + wind_degree: z.number().openapi({ + example: 210, + description: 'Wind direction in degrees', + }), + wind_dir: z.string().openapi({ + example: 'SSW', + description: 'Wind direction compass point', + }), + pressure_mb: z.number().openapi({ + example: 1013.2, + description: 'Atmospheric pressure in millibars', + }), + pressure_in: z.number().openapi({ + example: 29.92, + description: 'Atmospheric pressure in inches', + }), + precip_mm: z.number().openapi({ + example: 0.0, + description: 'Precipitation in millimeters', + }), + precip_in: z.number().openapi({ + example: 0.0, + description: 'Precipitation in inches', + }), + humidity: z.number().openapi({ + example: 65, + description: 'Humidity percentage', + }), + cloud: z.number().openapi({ + example: 25, + description: 'Cloud cover percentage', + }), + feelslike_c: z.number().openapi({ + example: 24.1, + description: 'Feels like temperature in Celsius', + }), + feelslike_f: z.number().openapi({ + example: 75.4, + description: 'Feels like temperature in Fahrenheit', + }), + vis_km: z.number().openapi({ + example: 10.0, + description: 'Visibility in kilometers', + }), + vis_miles: z.number().openapi({ + example: 6.0, + description: 'Visibility in miles', + }), + uv: z.number().openapi({ + example: 5, + description: 'UV index', + }), + gust_mph: z.number().optional().openapi({ + example: 12.5, + description: 'Wind gust speed in miles per hour', + }), + gust_kph: z.number().optional().openapi({ + example: 20.1, + description: 'Wind gust speed in kilometers per hour', + }), + // Additional fields from actual API response + is_day: z.number().optional().openapi({ + example: 1, + description: 'Whether it is day (1) or night (0)', + }), + windchill_c: z.number().optional().openapi({ + example: 22.5, + description: 'Wind chill temperature in Celsius', + }), + windchill_f: z.number().optional().openapi({ + example: 72.5, + description: 'Wind chill temperature in Fahrenheit', + }), + heatindex_c: z.number().optional().openapi({ + example: 24.1, + description: 'Heat index temperature in Celsius', + }), + heatindex_f: z.number().optional().openapi({ + example: 75.4, + description: 'Heat index temperature in Fahrenheit', + }), + dewpoint_c: z.number().optional().openapi({ + example: 18.0, + description: 'Dew point temperature in Celsius', + }), + dewpoint_f: z.number().optional().openapi({ + example: 64.4, + description: 'Dew point temperature in Fahrenheit', + }), + will_it_rain: z.number().optional().openapi({ + example: 0, + description: 'Will it rain (1) or not (0)', + }), + chance_of_rain: z.number().optional().openapi({ + example: 45, + description: 'Chance of rain percentage', + }), + will_it_snow: z.number().optional().openapi({ + example: 0, + description: 'Will it snow (1) or not (0)', + }), + chance_of_snow: z.number().optional().openapi({ + example: 10, + description: 'Chance of snow percentage', + }), + snow_cm: z.number().optional().openapi({ + example: 0.0, + description: 'Snowfall in centimeters', + }), + air_quality: AirQualitySchema.optional(), + short_rad: z.number().optional().openapi({ + example: 7.33, + description: 'Short wave radiation', + }), + diff_rad: z.number().optional().openapi({ + example: 3.78, + description: 'Diffuse radiation', + }), + dni: z.number().optional().openapi({ + example: 4.42, + description: 'Direct normal irradiance', + }), + gti: z.number().optional().openapi({ + example: 4.91, + description: 'Global tilted irradiance', + }), + }) + .openapi('WeatherCurrent'); + +export const WeatherDaySchema = z + .object({ + maxtemp_c: z.number().openapi({ + example: 25.0, + description: 'Maximum temperature in Celsius', + }), + maxtemp_f: z.number().openapi({ + example: 77.0, + description: 'Maximum temperature in Fahrenheit', + }), + mintemp_c: z.number().openapi({ + example: 18.0, + description: 'Minimum temperature in Celsius', + }), + mintemp_f: z.number().openapi({ + example: 64.4, + description: 'Minimum temperature in Fahrenheit', + }), + avgtemp_c: z.number().openapi({ + example: 21.5, + description: 'Average temperature in Celsius', + }), + avgtemp_f: z.number().openapi({ + example: 70.7, + description: 'Average temperature in Fahrenheit', + }), + maxwind_mph: z.number().openapi({ + example: 12.3, + description: 'Maximum wind speed in mph', + }), + maxwind_kph: z.number().openapi({ + example: 19.8, + description: 'Maximum wind speed in kph', + }), + totalprecip_mm: z.number().openapi({ + example: 0.5, + description: 'Total precipitation in mm', + }), + totalprecip_in: z.number().openapi({ + example: 0.02, + description: 'Total precipitation in inches', + }), + totalsnow_cm: z.number().openapi({ + example: 0.0, + description: 'Total snowfall in cm', + }), + avghumidity: z.number().openapi({ + example: 68, + description: 'Average humidity percentage', + }), + avgvis_km: z.number().openapi({ + example: 9.5, + description: 'Average visibility in km', + }), + avgvis_miles: z.number().openapi({ + example: 5.9, + description: 'Average visibility in miles', + }), + uv: z.number().openapi({ + example: 6, + description: 'UV index', + }), + condition: WeatherConditionSchema, + daily_chance_of_rain: z.number().optional().openapi({ + example: 45, + description: 'Daily chance of rain percentage', + }), + daily_chance_of_snow: z.number().optional().openapi({ + example: 10, + description: 'Daily chance of snow percentage', + }), + }) + .openapi('WeatherDay'); + +export const WeatherHourSchema = z + .object({ + time_epoch: z.number().openapi({ + example: 1704153600, + description: 'Hour timestamp as Unix epoch', + }), + time: z.string().openapi({ + example: '2024-01-01 12:00', + description: 'Hour time string', + }), + temp_c: z.number().openapi({ + example: 22.5, + description: 'Temperature in Celsius', + }), + temp_f: z.number().openapi({ + example: 72.5, + description: 'Temperature in Fahrenheit', + }), + condition: WeatherConditionSchema, + wind_mph: z.number().openapi({ + example: 8.5, + description: 'Wind speed in miles per hour', + }), + wind_kph: z.number().openapi({ + example: 13.7, + description: 'Wind speed in kilometers per hour', + }), + wind_degree: z.number().openapi({ + example: 210, + description: 'Wind direction in degrees', + }), + wind_dir: z.string().openapi({ + example: 'SSW', + description: 'Wind direction compass point', + }), + pressure_mb: z.number().openapi({ + example: 1013.2, + description: 'Atmospheric pressure in millibars', + }), + pressure_in: z.number().openapi({ + example: 29.92, + description: 'Atmospheric pressure in inches', + }), + precip_mm: z.number().openapi({ + example: 0.0, + description: 'Precipitation in millimeters', + }), + precip_in: z.number().openapi({ + example: 0.0, + description: 'Precipitation in inches', + }), + humidity: z.number().openapi({ + example: 65, + description: 'Humidity percentage', + }), + cloud: z.number().openapi({ + example: 25, + description: 'Cloud cover percentage', + }), + feelslike_c: z.number().openapi({ + example: 24.1, + description: 'Feels like temperature in Celsius', + }), + feelslike_f: z.number().openapi({ + example: 75.4, + description: 'Feels like temperature in Fahrenheit', + }), + vis_km: z.number().openapi({ + example: 10.0, + description: 'Visibility in kilometers', + }), + vis_miles: z.number().openapi({ + example: 6.0, + description: 'Visibility in miles', + }), + uv: z.number().openapi({ + example: 5, + description: 'UV index', + }), + gust_mph: z.number().optional().openapi({ + example: 12.5, + description: 'Wind gust speed in miles per hour', + }), + gust_kph: z.number().optional().openapi({ + example: 20.1, + description: 'Wind gust speed in kilometers per hour', + }), + chance_of_rain: z.number().optional().openapi({ + example: 45, + description: 'Chance of rain percentage', + }), + chance_of_snow: z.number().optional().openapi({ + example: 10, + description: 'Chance of snow percentage', + }), + // Additional fields from actual API response + is_day: z.number().optional().openapi({ + example: 1, + description: 'Whether it is day (1) or night (0)', + }), + windchill_c: z.number().optional().openapi({ + example: 22.5, + description: 'Wind chill temperature in Celsius', + }), + windchill_f: z.number().optional().openapi({ + example: 72.5, + description: 'Wind chill temperature in Fahrenheit', + }), + heatindex_c: z.number().optional().openapi({ + example: 24.1, + description: 'Heat index temperature in Celsius', + }), + heatindex_f: z.number().optional().openapi({ + example: 75.4, + description: 'Heat index temperature in Fahrenheit', + }), + dewpoint_c: z.number().optional().openapi({ + example: 18.0, + description: 'Dew point temperature in Celsius', + }), + dewpoint_f: z.number().optional().openapi({ + example: 64.4, + description: 'Dew point temperature in Fahrenheit', + }), + will_it_rain: z.number().optional().openapi({ + example: 0, + description: 'Will it rain (1) or not (0)', + }), + will_it_snow: z.number().optional().openapi({ + example: 0, + description: 'Will it snow (1) or not (0)', + }), + snow_cm: z.number().optional().openapi({ + example: 0.0, + description: 'Snowfall in centimeters', + }), + air_quality: AirQualitySchema.optional(), + short_rad: z.number().optional().openapi({ + example: 7.33, + description: 'Short wave radiation', + }), + diff_rad: z.number().optional().openapi({ + example: 3.78, + description: 'Diffuse radiation', + }), + dni: z.number().optional().openapi({ + example: 4.42, + description: 'Direct normal irradiance', + }), + gti: z.number().optional().openapi({ + example: 4.91, + description: 'Global tilted irradiance', + }), + }) + .openapi('WeatherHour'); + +export const WeatherForecastDaySchema = z + .object({ + date: z.string().openapi({ + example: '2024-01-01', + description: 'Forecast date', + }), + date_epoch: z.number().openapi({ + example: 1704067200, + description: 'Forecast date as Unix timestamp', + }), + day: WeatherDaySchema, + astro: z + .object({ + sunrise: z.string().openapi({ + example: '07:00 AM', + description: 'Sunrise time', + }), + sunset: z.string().openapi({ + example: '05:30 PM', + description: 'Sunset time', + }), + moonrise: z.string().openapi({ + example: '08:00 PM', + description: 'Moonrise time', + }), + moonset: z.string().openapi({ + example: '06:00 AM', + description: 'Moonset time', + }), + moon_phase: z.string().openapi({ + example: 'Waning Gibbous', + description: 'Moon phase', + }), + moon_illumination: z.number().openapi({ + example: 85, + description: 'Moon illumination percentage', + }), + }) + .optional() + .openapi('WeatherAstro'), + hour: z.array(WeatherHourSchema).optional().openapi({ + description: 'Hourly forecast data', + }), + }) + .openapi('WeatherForecastDay'); + +export const WeatherAlertSchema = z + .object({ + alert: z + .array( + z.object({ + headline: z.string().openapi({ + example: 'Severe Weather Warning', + description: 'Alert headline', + }), + msgtype: z.string().openapi({ + example: 'Warning', + description: 'Alert message type', + }), + severity: z.string().openapi({ + example: 'Moderate', + description: 'Alert severity level', + }), + urgency: z.string().openapi({ + example: 'Expected', + description: 'Alert urgency', + }), + areas: z.string().openapi({ + example: 'Boston, MA', + description: 'Affected areas', + }), + category: z.string().openapi({ + example: 'Met', + description: 'Alert category', + }), + certainty: z.string().openapi({ + example: 'Likely', + description: 'Alert certainty', + }), + event: z.string().openapi({ + example: 'Severe Thunderstorm', + description: 'Weather event type', + }), + note: z.string().optional().openapi({ + example: 'Take shelter immediately', + description: 'Additional alert notes', + }), + effective: z.string().openapi({ + example: '2024-01-01 12:00', + description: 'Alert effective time', + }), + expires: z.string().openapi({ + example: '2024-01-01 18:00', + description: 'Alert expiration time', + }), + desc: z.string().openapi({ + example: 'Severe thunderstorm warning in effect', + description: 'Alert description', + }), + instruction: z.string().optional().openapi({ + example: 'Move to interior room', + description: 'Safety instructions', + }), + }), + ) + .optional() + .openapi('WeatherAlert'), + }) + .optional() + .openapi('WeatherAlerts'); + +export const WeatherForecastSchema = z + .object({ + location: WeatherAPILocationSchema, + current: WeatherCurrentSchema, + forecast: z.object({ + forecastday: z.array(WeatherForecastDaySchema), + }), + }) + .openapi('WeatherForecast'); + +// Raw WeatherAPI.com response schemas for internal use +export const WeatherAPISearchResponseSchema = z + .array( + z.object({ + id: z.number().optional(), + name: z.string(), + region: z.string(), + country: z.string(), + lat: z.union([z.string(), z.number()]), + lon: z.union([z.string(), z.number()]), + url: z.string().optional(), + }), + ) + .openapi('WeatherAPISearchResponse'); + +export const WeatherAPICurrentResponseSchema = z + .object({ + location: WeatherAPILocationSchema, + current: WeatherCurrentSchema, + }) + .openapi('WeatherAPICurrentResponse'); + +export const WeatherAPIForecastResponseSchema = z + .object({ + location: WeatherAPILocationSchema, + current: WeatherCurrentSchema, + forecast: z.object({ + forecastday: z.array(WeatherForecastDaySchema), + }), + alerts: WeatherAlertSchema.optional(), + }) + .openapi('WeatherAPIForecastResponse'); + +export const LocationSearchResponseSchema = z + .array(LocationSchema) + .openapi('LocationSearchResponse'); + +// Export types for use in the application +export type Location = z.infer; +export type WeatherAPILocation = z.infer; +export type WeatherCondition = z.infer; +export type AirQuality = z.infer; +export type WeatherCurrent = z.infer; +export type WeatherDay = z.infer; +export type WeatherHour = z.infer; +export type WeatherForecastDay = z.infer; +export type WeatherAlert = z.infer; +export type WeatherForecast = z.infer; +export type WeatherAPISearchResponse = z.infer; +export type WeatherAPICurrentResponse = z.infer; +export type WeatherAPIForecastResponse = z.infer; +export type LocationSearchResponse = z.infer; diff --git a/packages/api/src/services/LogsQueueConsumer.ts b/packages/api/src/services/LogsQueueConsumer.ts deleted file mode 100644 index 618be0395b..0000000000 --- a/packages/api/src/services/LogsQueueConsumer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { MessageBatch } from '@cloudflare/workers-types'; -import type { Env } from '@packrat/api/utils/env-validation'; -import { createDbClient } from '../db'; -import { invalidItemLogs, type NewInvalidItemLog } from '../db/schema'; -import { updateEtlJobProgress } from './etl/updateEtlJobProgress'; - -export class LogsQueueConsumer { - async handle(batch: MessageBatch, env: Env): Promise { - const db = createDbClient(env); - - for (const message of batch.messages) { - const { - id: jobId, - totalItemsCount, - data: logs, - } = message.body as { id: string; totalItemsCount: number; data: NewInvalidItemLog[] }; - - try { - await db.insert(invalidItemLogs).values(logs); - await updateEtlJobProgress(env, jobId, { - invalid: logs.length, - total: totalItemsCount, - }); - - console.log(`πŸ“ Processed and wrote ${logs.length} invalid items for job ${jobId}`); - } catch (error) { - console.error(`Failed to process log message:`, error); - } - } - } -} diff --git a/packages/api/src/services/aiService.ts b/packages/api/src/services/aiService.ts index a7b46ac69e..d00a46468b 100644 --- a/packages/api/src/services/aiService.ts +++ b/packages/api/src/services/aiService.ts @@ -1,7 +1,7 @@ import { createPerplexity } from '@ai-sdk/perplexity'; import type { AutoRAG } from '@cloudflare/workers-types'; +import type { Env } from '@packrat/api/types/env'; import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; -import type { Env } from '@packrat/api/utils/env-validation'; import { getEnv } from '@packrat/api/utils/env-validation'; import { generateText } from 'ai'; import type { Context } from 'hono'; diff --git a/packages/api/src/services/catalogService.ts b/packages/api/src/services/catalogService.ts index 9950b9b1c3..55ca60fddc 100644 --- a/packages/api/src/services/catalogService.ts +++ b/packages/api/src/services/catalogService.ts @@ -6,7 +6,7 @@ import { type NewCatalogItem, } from '@packrat/api/db/schema'; import { generateEmbedding, generateManyEmbeddings } from '@packrat/api/services/embeddingService'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; import { and, @@ -32,7 +32,7 @@ const isContext = (contextOrEnv: Context | Env, isContext: boolean): contextOrEn export class CatalogService { private db; - private env; + private env: Env; constructor(contextOrEnv: Context | Env, isHonoContext: boolean = true) { if (isContext(contextOrEnv, isHonoContext)) { @@ -70,17 +70,18 @@ export class CatalogService { throw new Error('Offset cannot be negative'); } - const conditions = []; + const conditions: SQL[] = []; if (q) { - conditions.push( - or( - ilike(catalogItems.name, `%${q}%`), - ilike(catalogItems.description, `%${q}%`), - ilike(catalogItems.brand, `%${q}%`), - ilike(catalogItems.model, `%${q}%`), - ilike(catalogItems.categories, `%${q}%`), - ), + const searchCondition = or( + ilike(catalogItems.name, `%${q}%`), + ilike(catalogItems.description, `%${q}%`), + ilike(catalogItems.brand, `%${q}%`), + ilike(catalogItems.model, `%${q}%`), + ilike(sql`${catalogItems.categories}::text`, `%${q}%`), ); + if (searchCondition) { + conditions.push(searchCondition); + } } if (category) { @@ -167,8 +168,19 @@ export class CatalogService { provider: this.env.AI_PROVIDER, cloudflareAccountId: this.env.CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: this.env.CLOUDFLARE_AI_GATEWAY_ID, + cloudflareAiBinding: this.env.AI, }); + if (!embedding) { + return { + items: [], + total: 0, + limit, + offset, + nextOffset: offset + limit, + }; + } + const similarity = sql`1 - (${cosineDistance(catalogItems.embedding, embedding)})`; const { embedding: _embedding, ...columnsToSelect } = getTableColumns(catalogItems); @@ -219,9 +231,20 @@ export class CatalogService { cloudflareAccountId: this.env.CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: this.env.CLOUDFLARE_AI_GATEWAY_ID, provider: this.env.AI_PROVIDER, + cloudflareAiBinding: this.env.AI, }); + if (!embeddings) { + return { + items: [], + }; + } + const searchTasks = embeddings.map((embedding) => { + if (!embedding) { + return Promise.resolve([]); + } + const similarity = sql`1 - (${cosineDistance(catalogItems.embedding, embedding)})`; const { embedding: _embedding, ...columnsToSelect } = getTableColumns(catalogItems); return this.db @@ -306,6 +329,7 @@ export class CatalogService { cloudflareAccountId: this.env.CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: this.env.CLOUDFLARE_AI_GATEWAY_ID, provider: this.env.AI_PROVIDER, + cloudflareAiBinding: this.env.AI, }); // Update items with new embeddings @@ -399,6 +423,7 @@ export class CatalogService { cloudflareAccountId: this.env.CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: this.env.CLOUDFLARE_AI_GATEWAY_ID, provider: this.env.AI_PROVIDER, + cloudflareAiBinding: this.env.AI, }); // Update items with embeddings diff --git a/packages/api/src/services/embeddingService.ts b/packages/api/src/services/embeddingService.ts index 4be50644b6..997ff1dd06 100644 --- a/packages/api/src/services/embeddingService.ts +++ b/packages/api/src/services/embeddingService.ts @@ -1,12 +1,14 @@ +import type { Env } from '@packrat/api/types/env'; +import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; import { type AIProvider, createAIProvider } from '@packrat/api/utils/ai/provider'; import { embed, embedMany } from 'ai'; -import { DEFAULT_MODELS } from '../utils/ai/models'; type GenerateEmbeddingBaseParams = { openAiApiKey: string; provider: AIProvider; cloudflareAccountId: string; cloudflareGatewayId: string; + cloudflareAiBinding: Env['AI']; }; type GenerateEmbeddingParams = GenerateEmbeddingBaseParams & { diff --git a/packages/api/src/services/etl/processCatalogEtl.ts b/packages/api/src/services/etl/processCatalogEtl.ts index 902a82a63d..87a289e027 100644 --- a/packages/api/src/services/etl/processCatalogEtl.ts +++ b/packages/api/src/services/etl/processCatalogEtl.ts @@ -1,15 +1,31 @@ import { createDbClient } from '@packrat/api/db'; import { etlJobs, type NewCatalogItem, type NewInvalidItemLog } from '@packrat/api/db/schema'; +import { mapCsvRowToItem } from '@packrat/api/utils/csv-utils'; import type { Env } from '@packrat/api/utils/env-validation'; -import { parse } from 'csv-parse/sync'; +import { parse } from 'csv-parse'; import { eq } from 'drizzle-orm'; import { R2BucketService } from '../r2-bucket'; import { CatalogItemValidator } from './CatalogItemValidator'; +import { processLogsBatch } from './processLogsBatch'; +import { processValidItemsBatch } from './processValidItemsBatch'; import { queueCatalogETL } from './queue'; -import { type CatalogETLMessage, QueueType } from './types'; +import type { CatalogETLMessage } from './types'; -export const CHUNK_SIZE = 5000; -export const BATCH_SIZE = 10; +export const BATCH_SIZE = 100; + +async function* streamToText(stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} export async function processCatalogETL({ message, @@ -22,9 +38,10 @@ export async function processCatalogETL({ const jobId = message.id; const db = createDbClient(env); + try { console.log( - `πŸš€ Starting ETL job ${jobId} for file ${objectKey} (rows ${startRow} to ${startRow + CHUNK_SIZE - 1})`, + `πŸ”„ Processing ETL batch (rows ${startRow} to ${startRow + BATCH_SIZE - 1}) for file ${objectKey}, job ${jobId}`, ); const r2Service = new R2BucketService({ @@ -32,28 +49,37 @@ export async function processCatalogETL({ bucketType: 'catalog', }); - const object = await r2Service.get(objectKey); - if (!object) { - throw new Error(`Object not found: ${objectKey}`); + console.log(`πŸ” [TRACE] Getting stream for object: ${objectKey}`); + const r2Object = await r2Service.get(objectKey); + if (!r2Object) { + throw new Error(`Failed to get stream for object: ${objectKey}`); } - const text = await object.text(); - const rows: string[][] = parse(text, { - relax_column_count: true, - skip_empty_lines: true, - }); - - let isHeader = true; + let rowIndex = 0; let fieldMap: Record = {}; + let isHeaderProcessed = false; let validItemsBatch: Partial[] = []; let invalidItemsBatch: NewInvalidItemLog[] = []; const validator = new CatalogItemValidator(); - for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { - const row = rows[rowIndex]; + console.log(`πŸ” [TRACE] Starting streaming process - jobId: ${jobId}, startRow: ${startRow}`); + const parser = parse({ + relax_column_count: true, + skip_empty_lines: true, + }); + + (async () => { + for await (const chunk of streamToText(r2Object.body)) { + parser.write(chunk); + } + parser.end(); + })(); - if (isHeader) { + for await (const record of parser) { + await new Promise((resolve) => setTimeout(resolve, 1)); // Yield to event loop for GC Opportunities to prevent memory bloat + const row = record as string[]; + if (!isHeaderProcessed) { fieldMap = row.reduce( (acc, header, idx) => { acc[header.trim()] = idx; @@ -61,17 +87,24 @@ export async function processCatalogETL({ }, {} as Record, ); - isHeader = false; - console.log(`πŸ“‹ Processing ${objectKey} with field mapping:`, Object.keys(fieldMap)); + isHeaderProcessed = true; + console.log( + `πŸ” [TRACE] Header processed - fields: ${Object.keys(fieldMap).length}, mapping:`, + Object.keys(fieldMap), + ); continue; } - // Only process rows in the current chunk - const dataRowIndex = rowIndex - 1; // -1 because header is row 0 - if (dataRowIndex < startRow) continue; - if (dataRowIndex >= startRow + CHUNK_SIZE) break; + if (rowIndex < startRow) { + rowIndex++; + continue; + } + if (rowIndex >= startRow + BATCH_SIZE) { + break; + } const item = mapCsvRowToItem({ values: row, fieldMap }); + if (item) { const validatedItem = validator.validateItem(item); @@ -89,45 +122,60 @@ export async function processCatalogETL({ } if (validItemsBatch.length >= BATCH_SIZE) { - await env.ETL_QUEUE.send({ - type: QueueType.CATALOG_ETL_WRITE_BATCH, - id: jobId, - timestamp: Date.now(), - data: { items: validItemsBatch, total: rows.length - 1 }, // -1 for header + await processValidItemsBatch({ + jobId, + items: validItemsBatch, + env, }); validItemsBatch = []; await new Promise((r) => setTimeout(r, 1)); } if (invalidItemsBatch.length >= BATCH_SIZE) { - await env.LOGS_QUEUE.send({ - data: invalidItemsBatch, - id: jobId, - totalItemsCount: rows.length - 1, + await processLogsBatch({ + jobId, + logs: invalidItemsBatch, + env, }); invalidItemsBatch = []; } + + rowIndex++; + if (rowIndex % 100 === 0) { + console.log(`πŸ” [TRACE] Progress update - rows processed: ${rowIndex}`); + } } + console.log(`πŸ” [TRACE] Streaming complete - processing final batches`); + + // Flush remaining batches if (validItemsBatch.length > 0) { - await env.ETL_QUEUE.send({ - type: QueueType.CATALOG_ETL_WRITE_BATCH, - id: jobId, - timestamp: Date.now(), - data: { items: validItemsBatch, total: rows.length - 1 }, + console.log( + `πŸ” [TRACE] Processing final valid items batch - size: ${validItemsBatch.length}`, + ); + await processValidItemsBatch({ + jobId, + items: validItemsBatch, + env, }); } if (invalidItemsBatch.length > 0) { - await env.LOGS_QUEUE.send({ - id: jobId, - data: invalidItemsBatch, - totalItemsCount: rows.length - 1, + console.log( + `πŸ” [TRACE] Processing final invalid items batch - size: ${invalidItemsBatch.length}`, + ); + await processLogsBatch({ + jobId, + logs: invalidItemsBatch, + env, }); } - if (rows.length - 1 > startRow + CHUNK_SIZE) { - // If more rows remain, enqueue next chunk + // Queue next batch if needed + if (rowIndex >= startRow + BATCH_SIZE) { + console.log( + `πŸ” [TRACE] Queueing next batch - currentRow: ${rowIndex}, nextStartRow: ${startRow + BATCH_SIZE}`, + ); await queueCatalogETL({ queue: env.ETL_QUEUE, objectKey, @@ -135,304 +183,28 @@ export async function processCatalogETL({ source, scraperRevision, jobId, - startRow: startRow + CHUNK_SIZE, + startRow: startRow + BATCH_SIZE, }); console.log( - `➑️ Queued next ETL chunk for rows ${startRow + CHUNK_SIZE} to ${startRow + 2 * CHUNK_SIZE - 1}`, + `➑️ Queued next ETL batch for rows ${startRow + BATCH_SIZE} to ${startRow + 2 * BATCH_SIZE - 1}`, ); + } else { + console.log('πŸ” [TRACE] No more batches needed - processed all rows'); + await db + .update(etlJobs) + .set({ totalCount: rowIndex, status: 'completed', completedAt: new Date() }) + .where(eq(etlJobs.id, jobId)); } } catch (error) { await db .update(etlJobs) .set({ status: 'failed', completedAt: new Date() }) .where(eq(etlJobs.id, jobId)); - console.error(`❌ ETL job ${jobId} failed:`, error); + console.error( + `❌ Error processing ETL batch (rows ${startRow} to ${startRow + BATCH_SIZE - 1}), job ${jobId}:`, + error, + ); throw error; } } - -function mapCsvRowToItem({ - values, - fieldMap, -}: { - values: string[]; - fieldMap: Record; -}): Partial | null { - const item: Partial = {}; - // --- Optional Scalars --- - item.description = - fieldMap.description !== undefined - ? values[fieldMap.description]?.replace(/[\r\n]+/g, ' ').trim() - : undefined; - - const name = fieldMap.name !== undefined ? values[fieldMap.name]?.trim() : undefined; - item.name = name; - - const productUrl = - fieldMap.productUrl !== undefined ? values[fieldMap.productUrl]?.trim() : undefined; - item.productUrl = productUrl; - - const currency = fieldMap.currency !== undefined ? values[fieldMap.currency]?.trim() : undefined; - item.currency = currency; - - const reviewCountStr = - fieldMap.reviewCount !== undefined ? values[fieldMap.reviewCount] : undefined; - item.reviewCount = reviewCountStr ? parseInt(reviewCountStr) || 0 : 0; - - if (fieldMap.categories !== undefined && values[fieldMap.categories]) { - const val = values[fieldMap.categories].trim(); - try { - item.categories = val.startsWith('[') - ? JSON.parse(val) - : val - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - } catch { - item.categories = val ? [val] : undefined; - } - } else { - item.categories = undefined; - } - - let images: string[] | undefined; - if (fieldMap.images !== undefined && values[fieldMap.images]) { - try { - const val = values[fieldMap.images].trim(); - images = val.startsWith('[') - ? JSON.parse(val) - : val - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - } catch { - images = undefined; - } - } else { - images = undefined; - } - item.images = images; - - // Scalars - const weightStr = fieldMap.weight !== undefined ? values[fieldMap.weight] : undefined; - const unitStr = fieldMap.weightUnit !== undefined ? values[fieldMap.weightUnit] : undefined; - if (weightStr && parseFloat(weightStr) > 0) { - const { weight, unit } = parseWeight(weightStr, unitStr); - item.weight = weight || undefined; - item.weightUnit = unit || undefined; - } - - const priceStr = fieldMap.price !== undefined ? values[fieldMap.price] : undefined; - if (priceStr) item.price = parsePrice(priceStr); - - const ratingStr = fieldMap.ratingValue !== undefined ? values[fieldMap.ratingValue] : undefined; - if (ratingStr) item.ratingValue = parseFloat(ratingStr) || null; - - if (fieldMap.variants !== undefined && values[fieldMap.variants]) { - const val = values[fieldMap.variants].trim(); - try { - item.variants = JSON.parse(val); - } catch { - try { - item.variants = JSON.parse(val.replace(/'/g, '"')); - } catch { - item.variants = []; - } - } - } - - if (fieldMap.faqs !== undefined && values[fieldMap.faqs]) { - const val = values[fieldMap.faqs].trim(); - try { - item.faqs = parseFaqs(val); - } catch { - item.faqs = []; - } - } - - // JSON fields - const jsonFields: Extract<'links' | 'reviews' | 'qas', keyof NewCatalogItem>[] = [ - 'links', - 'reviews', - 'qas', - ]; - for (const field of jsonFields) { - if (fieldMap[field as string] !== undefined && values[fieldMap[field as string]]) { - try { - item[field] = safeJsonParse(values[fieldMap[field as string]]); - } catch { - item[field] = []; - } - } - } - - // Techs + fallback for weight - const techsStr = fieldMap.techs !== undefined ? values[fieldMap.techs] : undefined; - if (techsStr) { - try { - const parsed = safeJsonParse>(techsStr); - item.techs = Array.isArray(parsed) ? {} : parsed; - - if (!item.weight && !Array.isArray(parsed)) { - const claimedWeight = parsed['Claimed Weight'] || parsed.weight; - if (claimedWeight) { - const { weight, unit } = parseWeight(claimedWeight); - item.weight = weight || undefined; - item.weightUnit = unit || undefined; - } - } - } catch { - item.techs = {}; - } - } - - // Direct mappings for string fields - const stringFields = [ - 'brand', - 'model', - 'color', - 'size', - 'sku', - 'productSku', - 'seller', - 'material', - 'condition', - ] as const; - for (const field of stringFields) { - const index = fieldMap[field]; - if (index !== undefined && values[index]) { - item[field] = values[index].replace(/^"|"$/g, '').trim(); - } - } - - // Handle availability enum separately - if (fieldMap.availability !== undefined && values[fieldMap.availability]) { - item.availability = values[fieldMap.availability] - .replace(/^"|"$/g, '') - .trim() as NewCatalogItem['availability']; - } - - return item; -} - -function parseWeight( - weightStr: string, - unitStr?: string, -): { weight: number | null; unit: string | null } { - if (!weightStr) return { weight: null, unit: null }; - - const weightVal = parseFloat(weightStr); - if (Number.isNaN(weightVal) || weightVal < 0) { - return { weight: null, unit: null }; - } - - const hint = (unitStr || weightStr).toLowerCase(); - - if (hint.includes('oz')) { - return { weight: Math.round(weightVal * 28.35), unit: 'oz' }; - } - if (hint.includes('lb')) { - return { weight: Math.round(weightVal * 453.592), unit: 'lb' }; - } - if (hint.includes('kg')) { - return { weight: weightVal * 1000, unit: 'kg' }; - } - - return { weight: weightVal, unit: 'g' }; -} - -/** - * Normalizes a messy JSON-like string to make it more parseable by JSON.parse. - * Handles Python values, smart quotes, invalid escapes, trailing commas, and more. - */ -function normalizeJsonString(value: string): string { - return ( - value - .trim() - - // Replace Python-style null/booleans with JS equivalents - .replace(/\bNone\b/g, 'null') - .replace(/\bTrue\b/g, 'true') - .replace(/\bFalse\b/g, 'false') - - // Normalize smart/special quotes to standard quotes - .replace(/[β€˜β€™β€›β€Ήβ€Ί]/g, "'") - .replace(/[β€œβ€β€žβ€ŸΒ«Β»]/g, '"') - .replace(/[`]/g, '') - - // Convert object keys from 'key': to "key": - .replace(/([{,]\s*)'([^']+?)'\s*:/g, '$1"$2":') - - // Convert string values from 'value' to "escaped value" - .replace(/:\s*'(.*?)'(?=\s*[},])/g, (_, val) => { - const escaped = val - .replace(/\\/g, '\\\\') // Escape backslashes - .replace(/"/g, '\\"') // Escape double quotes - .replace(/\\n|\\r|\\b|\\t|\\f|\r?\n|\r|\b|\t|\f/g, '') // Remove newlines/control chars - .replace(/\u2028|\u2029/g, ''); // Remove special Unicode line separators - return `: "${escaped}"`; - }) - - // Decode \xNN hex escapes to characters - .replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) - - // Escape lone backslashes (e.g., \ not followed by valid escape) - .replace(/([^\\])\\(?![\\/"'bfnrtu])/g, '$1\\\\') - - // Remove trailing commas before closing braces/brackets - .replace(/,\s*([}\]])/g, '$1') - ); -} - -function safeJsonParse(value: string): T | [] { - if (!value || value === 'undefined' || value === 'null') return []; - - const normalized = normalizeJsonString(value); - - try { - return JSON.parse(normalized) as T; - } catch (err) { - console.warn('❌ Failed to parse JSON:', { - error: err, - originalInput: value, - normalizedInput: normalized, - }); - return []; - } -} - -export function parseFaqs(input: string): Array<{ question: string; answer: string }> { - if (!input || typeof input !== 'string') return []; - - const results: Array<{ question: string; answer: string }> = []; - - // Remove outer quotes - let cleaned = input.trim(); - if (cleaned.startsWith('"') && cleaned.endsWith('"')) { - cleaned = cleaned.slice(1, -1).replace(/\\"/g, '"'); - } - - // Replace smart quotes - cleaned = normalizeJsonString(cleaned); - - // Use a global regex to extract each question-answer block - const regex = - /{[^{}]*?['"]question['"]\s*:\s*['"](.+?)['"]\s*,\s*['"]answer['"]\s*:\s*['"](.+?)['"]\s*}/g; - - let match = regex.exec(cleaned); - while (match !== null) { - const question = match[1].trim(); - const answer = match[2].trim(); - results.push({ question, answer }); - - match = regex.exec(cleaned); - } - return results; -} - -function parsePrice(priceStr: string): number | null { - if (!priceStr) return null; - const price = parseFloat(priceStr.replace(/[^0-9.]/g, '')); - return Number.isNaN(price) ? null : price; -} diff --git a/packages/api/src/services/etl/processLogsBatch.ts b/packages/api/src/services/etl/processLogsBatch.ts new file mode 100644 index 0000000000..62574e36b1 --- /dev/null +++ b/packages/api/src/services/etl/processLogsBatch.ts @@ -0,0 +1,27 @@ +import type { Env } from '@packrat/api/utils/env-validation'; +import { createDbClient } from '../../db'; +import { invalidItemLogs, type NewInvalidItemLog } from '../../db/schema'; +import { updateEtlJobProgress } from './updateEtlJobProgress'; + +export async function processLogsBatch({ + jobId, + logs, + env, +}: { + jobId: string; + logs: NewInvalidItemLog[]; + env: Env; +}): Promise { + const db = createDbClient(env); + try { + await db.insert(invalidItemLogs).values(logs); + await updateEtlJobProgress(env, { + jobId, + invalid: logs.length, + }); + + console.log(`πŸ“ Processed and wrote ${logs.length} invalid items for job ${jobId}`); + } catch (error) { + console.error(`Failed to process log message:`, error); + } +} diff --git a/packages/api/src/services/etl/processCatalogETLWriteBatch.ts b/packages/api/src/services/etl/processValidItemsBatch.ts similarity index 82% rename from packages/api/src/services/etl/processCatalogETLWriteBatch.ts rename to packages/api/src/services/etl/processValidItemsBatch.ts index 26c6dde47a..d4a3e64e4d 100644 --- a/packages/api/src/services/etl/processCatalogETLWriteBatch.ts +++ b/packages/api/src/services/etl/processValidItemsBatch.ts @@ -1,22 +1,20 @@ import type { NewCatalogItem } from '@packrat/api/db/schema'; +import type { Env } from '@packrat/api/types/env'; import { getEmbeddingText } from '@packrat/api/utils/embeddingHelper'; -import type { Env } from '@packrat/api/utils/env-validation'; import { CatalogService } from '../catalogService'; import { generateManyEmbeddings } from '../embeddingService'; import { mergeItemsBySku } from './mergeItemsBySku'; -import type { CatalogETLWriteBatchMessage } from './types'; import { updateEtlJobProgress } from './updateEtlJobProgress'; -export async function processCatalogETLWriteBatch({ - message, +export async function processValidItemsBatch({ + jobId, + items, env, }: { - message: CatalogETLWriteBatchMessage; + jobId: string; + items: Partial[]; env: Env; }): Promise { - const jobId = message.id; - const { items, total } = message.data; - const catalogService = new CatalogService(env, false); // Consolidate items with identical SKUs before upserting to avoid conflicting duplicate upserts. @@ -33,6 +31,7 @@ export async function processCatalogETLWriteBatch({ cloudflareAccountId: env.CLOUDFLARE_ACCOUNT_ID, cloudflareGatewayId: env.CLOUDFLARE_AI_GATEWAY_ID, provider: env.AI_PROVIDER, + cloudflareAiBinding: env.AI, }); // Combine items with their embeddings @@ -45,18 +44,18 @@ export async function processCatalogETLWriteBatch({ // Track the ETL job that processed these items await catalogService.trackEtlJob(upsertedItems, jobId); // Update the ETL job progress - await updateEtlJobProgress(env, jobId, { + await updateEtlJobProgress(env, { + jobId, valid: items.length, - total, }); } catch (error) { console.error(`Error generating embeddings for batch ${jobId}:`, error); // Fall back to processing without embeddings const upsertedItems = await catalogService.upsertCatalogItems(mergedItems); await catalogService.trackEtlJob(upsertedItems, jobId); - await updateEtlJobProgress(env, jobId, { + await updateEtlJobProgress(env, { + jobId, valid: items.length, - total, }); } finally { console.log(`πŸ“¦ Batch ${jobId}: Processed ${items.length} valid items`); diff --git a/packages/api/src/services/etl/queue.ts b/packages/api/src/services/etl/queue.ts index d4e54f6402..4568e15c52 100644 --- a/packages/api/src/services/etl/queue.ts +++ b/packages/api/src/services/etl/queue.ts @@ -1,30 +1,7 @@ import type { MessageBatch, Queue } from '@cloudflare/workers-types'; import type { Env } from '@packrat/api/utils/env-validation'; -import { processCatalogETLWriteBatch } from './processCatalogETLWriteBatch'; import { processCatalogETL } from './processCatalogEtl'; -import type { CatalogETLWriteBatchMessage } from './types'; - -export enum QueueType { - CATALOG_ETL = 'catalog-etl', - CATALOG_ETL_WRITE_BATCH = 'catalog-etl-write-batch', -} - -export interface BaseQueueMessage { - type: QueueType; - timestamp: number; - id: string; -} - -export interface CatalogETLMessage extends BaseQueueMessage { - type: QueueType.CATALOG_ETL; - data: { - objectKey: string; - userId: string; - source: string; - scraperRevision: string; - startRow?: number; // for chunking - }; -} +import type { CatalogETLMessage } from './types'; export async function queueCatalogETL({ queue, @@ -44,7 +21,6 @@ export async function queueCatalogETL({ startRow?: number; }): Promise { const message: CatalogETLMessage = { - type: QueueType.CATALOG_ETL, data: { objectKey, userId, source, scraperRevision, startRow }, timestamp: Date.now(), id: jobId, @@ -58,31 +34,16 @@ export async function processQueueBatch({ batch, env, }: { - batch: MessageBatch; + batch: MessageBatch; env: Env; }): Promise { for (const message of batch.messages) { try { - const queueMessage: BaseQueueMessage = message.body; - - switch (queueMessage.type) { - case QueueType.CATALOG_ETL: - await processCatalogETL({ - message: queueMessage as CatalogETLMessage, - env, - }); - break; - - case QueueType.CATALOG_ETL_WRITE_BATCH: - await processCatalogETLWriteBatch({ - message: queueMessage as CatalogETLWriteBatchMessage, - env, - }); - break; - - default: - console.warn(`Unknown queue message type: ${queueMessage.type}`); - } + const queueMessage: CatalogETLMessage = message.body; + await processCatalogETL({ + message: queueMessage, + env, + }); } catch (error) { console.error('Error processing queue message:', error); } diff --git a/packages/api/src/services/etl/types.ts b/packages/api/src/services/etl/types.ts index 148b55e522..cb58912ffd 100644 --- a/packages/api/src/services/etl/types.ts +++ b/packages/api/src/services/etl/types.ts @@ -1,33 +1,14 @@ import type { Queue } from '@cloudflare/workers-types'; -import type { NewCatalogItem } from '@packrat/api/db/schema'; -export enum QueueType { - CATALOG_ETL = 'catalog-etl', - CATALOG_ETL_WRITE_BATCH = 'catalog-etl-write-batch', -} - -export interface BaseQueueMessage { - type: QueueType; +export interface CatalogETLMessage { timestamp: number; id: string; -} - -export interface CatalogETLMessage extends BaseQueueMessage { - type: QueueType.CATALOG_ETL; data: { objectKey: string; userId: string; source: string; scraperRevision: string; - startRow?: number; - }; -} - -export interface CatalogETLWriteBatchMessage extends BaseQueueMessage { - type: QueueType.CATALOG_ETL_WRITE_BATCH; - data: { - items: Partial[]; - total: number; + startRow?: number; // for chunking }; } diff --git a/packages/api/src/services/etl/updateEtlJobProgress.ts b/packages/api/src/services/etl/updateEtlJobProgress.ts index a84d252571..b160befbcb 100644 --- a/packages/api/src/services/etl/updateEtlJobProgress.ts +++ b/packages/api/src/services/etl/updateEtlJobProgress.ts @@ -1,35 +1,22 @@ import { createDbClient } from '@packrat/api/db'; import { etlJobs } from '@packrat/api/db/schema'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { eq, sql } from 'drizzle-orm'; export async function updateEtlJobProgress( env: Env, - jobId: string, - update: { valid?: number; invalid?: number; total: number }, + params: { jobId: string; valid?: number; invalid?: number }, ): Promise { const db = createDbClient(env); - const valid = update?.valid ?? 0; - const invalid = update?.invalid ?? 0; + const valid = params?.valid ?? 0; + const invalid = params?.invalid ?? 0; - // Use atomic SQL operations to prevent race conditions await db .update(etlJobs) .set({ - totalProcessed: sql`COALESCE(${etlJobs.totalProcessed}, 0) + ${valid + invalid}`, totalValid: sql`COALESCE(${etlJobs.totalValid}, 0) + ${valid}`, totalInvalid: sql`COALESCE(${etlJobs.totalInvalid}, 0) + ${invalid}`, - status: sql`CASE - WHEN COALESCE(${etlJobs.totalProcessed}, 0) + ${valid + invalid} >= ${update.total} - THEN 'completed' - ELSE ${etlJobs.status} - END`, - completedAt: sql`CASE - WHEN COALESCE(${etlJobs.totalProcessed}, 0) + ${valid + invalid} >= ${update.total} - THEN CURRENT_TIMESTAMP - ELSE ${etlJobs.completedAt} - END`, }) - .where(eq(etlJobs.id, jobId)); + .where(eq(etlJobs.id, params.jobId)); } diff --git a/packages/api/src/services/packService.ts b/packages/api/src/services/packService.ts index 815b45e043..55e476cde3 100644 --- a/packages/api/src/services/packService.ts +++ b/packages/api/src/services/packService.ts @@ -9,6 +9,7 @@ import { packs, } from '@packrat/api/db/schema'; import { DEFAULT_MODELS } from '@packrat/api/utils/ai/models'; +import { getEnv } from '@packrat/api/utils/env-validation'; import { generateObject } from 'ai'; import { and, eq } from 'drizzle-orm'; import type { Context } from 'hono'; @@ -116,10 +117,11 @@ export class PackService { ...concept, items: concept.items.map((item, idx) => ({ requestedItem: item, - candidateItems: searchResults[idx].map((item) => { - const { reviews: _reviews, ...rest } = item; // remove unhelpful fields to manage context - return rest; - }), + candidateItems: + searchResults[idx]?.map((item) => { + const { reviews: _reviews, ...rest } = item; // remove unhelpful fields to manage context + return rest; + }) ?? [], })), }; }), @@ -166,12 +168,13 @@ export class PackService { } private async generatePackConcepts(count: number): Promise { + const { OPENAI_API_KEY } = getEnv(this.c); const openai = createOpenAI({ - apiKey: getEnv(this.c, 'OPENAI_API_KEY'), + apiKey: OPENAI_API_KEY, }); const { object } = await generateObject({ - model: openai(DEFAULT_MODELS.OPENAI_CHAT), + model: openai(DEFAULT_MODELS.CHAT), output: 'array', schema: packConceptSchema, system: PACK_CONCEPTS_SYSTEM_PROMPT, @@ -184,7 +187,43 @@ export class PackService { private async searchCatalog(items: string[]): Promise { const catalogService = new CatalogService(this.c); const searchResults = await catalogService.batchSemanticSearch(items); - return searchResults.items; + // Map each group to add the missing fields back + return searchResults.items.map((group) => + group.map((item) => ({ + id: item.id, + name: item.name, + productUrl: item.productUrl, + sku: item.sku, + weight: item.weight, + weightUnit: item.weightUnit, + description: item.description, + categories: item.categories, + images: item.images, + brand: item.brand, + model: item.model, + ratingValue: item.ratingValue, + color: item.color, + size: item.size, + price: item.price, + availability: item.availability, + seller: item.seller, + productSku: item.productSku, + material: item.material, + currency: item.currency, + condition: item.condition, + reviewCount: item.reviewCount, + variants: item.variants, + techs: item.techs, + links: item.links, + reviews: item.reviews, + qas: item.qas, + faqs: item.faqs, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + embedding: null, + similarity: item.similarity, + })), + ); } private async constructPacks( @@ -195,7 +234,7 @@ export class PackService { }); const { object } = await generateObject({ - model: openai(DEFAULT_MODELS.OPENAI_CHAT), + model: openai(DEFAULT_MODELS.CHAT), output: 'array', schema: finalPackSchema, system: PACKS_CONSTRUCTION_SYSTEM_PROMPT, diff --git a/packages/api/src/services/r2-bucket.ts b/packages/api/src/services/r2-bucket.ts index c003fe6de5..faf713046a 100644 --- a/packages/api/src/services/r2-bucket.ts +++ b/packages/api/src/services/r2-bucket.ts @@ -12,7 +12,8 @@ import { S3Client, UploadPartCommand, } from '@aws-sdk/client-s3'; -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; +import { isString } from 'radash'; // Define our own types to avoid conflicts with Cloudflare Workers types interface R2HTTPMetadata { @@ -24,6 +25,25 @@ interface R2HTTPMetadata { cacheExpiry?: Date; } +function isR2HTTPMetadata(obj: unknown): obj is R2HTTPMetadata { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + // Type-safe property checking + const record = obj as { [key: string]: unknown }; + + // Check that all properties, if present, are the correct type + return ( + (record.contentType === undefined || typeof record.contentType === 'string') && + (record.contentLanguage === undefined || typeof record.contentLanguage === 'string') && + (record.contentDisposition === undefined || typeof record.contentDisposition === 'string') && + (record.contentEncoding === undefined || typeof record.contentEncoding === 'string') && + (record.cacheControl === undefined || typeof record.cacheControl === 'string') && + (record.cacheExpiry === undefined || record.cacheExpiry instanceof Date) + ); +} + interface R2Checksums { md5?: ArrayBuffer; sha1?: ArrayBuffer; @@ -195,7 +215,7 @@ export class R2BucketService { const response = await this.s3Client.send(command); - return this.createR2Object(key, response); + return this.createR2Object(key, { ...response }); } catch (error: unknown) { if (error && typeof error === 'object' && 'name' in error) { const errorObj = error as { @@ -224,47 +244,97 @@ export class R2BucketService { }); const response = await this.s3Client.send(command); + const body = response.Body; - if (!response.Body) { + if (!body) { return null; } - const stream = response.Body as Readable; - const chunks: Uint8Array[] = []; + const r2Object = this.createR2Object(key, response); - // Collect all chunks - for await (const chunk of stream) { - chunks.push(chunk); - } + let streamConsumed = false; + let webStream: ReadableStream; - const bodyArray = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); - let offset = 0; - for (const chunk of chunks) { - bodyArray.set(chunk, offset); - offset += chunk.length; - } + const getStream = () => { + if (webStream) { + return webStream; + } - const r2Object = this.createR2Object(key, response); + // Check if it's a Node.js stream (like in a local Node environment) + if ('on' in body && typeof body.on === 'function') { + const nodeStream = body as Readable; + webStream = new globalThis.ReadableStream({ + start(controller) { + nodeStream.on('data', (chunk) => controller.enqueue(chunk)); + nodeStream.on('end', () => controller.close()); + nodeStream.on('error', (err) => controller.error(err)); + }, + cancel() { + nodeStream.destroy(); + }, + }); + } else { + // Assume it's a web stream (like in Cloudflare Workers) + webStream = body as ReadableStream; + } + return webStream; + }; + + const consumeStream = async (): Promise => { + const reader = getStream().getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + + const bodyArray = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + bodyArray.set(chunk, offset); + offset += chunk.length; + } + return bodyArray; + }; + + const assertStreamNotConsumed = () => { + if (streamConsumed) { + throw new Error('Body already used'); + } + streamConsumed = true; + }; // Create a proper R2ObjectBody const objectBody: R2ObjectBody = { ...r2Object, get body(): ReadableStream { - return new globalThis.ReadableStream({ - start(controller) { - controller.enqueue(bodyArray); - controller.close(); - }, - }); + assertStreamNotConsumed(); + return getStream(); }, get bodyUsed(): boolean { - return false; + return streamConsumed; + }, + arrayBuffer: async () => { + assertStreamNotConsumed(); + return (await consumeStream()).buffer as ArrayBuffer; + }, + bytes: async () => { + assertStreamNotConsumed(); + return consumeStream(); + }, + text: async () => { + assertStreamNotConsumed(); + return new TextDecoder().decode(await consumeStream()); + }, + json: async () => { + assertStreamNotConsumed(); + return JSON.parse(new TextDecoder().decode(await consumeStream())) as T; + }, + blob: async () => { + assertStreamNotConsumed(); + return new globalThis.Blob([await consumeStream()]); }, - arrayBuffer: async () => bodyArray.buffer, - bytes: async () => bodyArray, - text: async () => new TextDecoder().decode(bodyArray), - json: async () => JSON.parse(new TextDecoder().decode(bodyArray)) as T, - blob: async () => new globalThis.Blob([bodyArray]), }; return objectBody; @@ -345,29 +415,7 @@ export class R2BucketService { throw new Error('Cannot put null value'); } - let body: Buffer | Uint8Array | string; - - if (value instanceof globalThis.ReadableStream) { - const reader = value.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value: chunk } = await reader.read(); - if (done) break; - chunks.push(chunk); - } - body = Buffer.concat(chunks); - } else if (value instanceof ArrayBuffer) { - body = new Uint8Array(value); - } else if (ArrayBuffer.isView(value)) { - body = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } else if (typeof value === 'string') { - body = value; - } else if (value instanceof globalThis.Blob) { - body = new Uint8Array(await value.arrayBuffer()); - } else { - throw new Error('Unsupported value type'); - } - + const body = await this.convertBodyToUploadable(value); const httpMetadata = this.extractHttpMetadata(options?.httpMetadata); const command = new PutObjectCommand({ @@ -460,28 +508,7 @@ export class R2BucketService { value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, _options?: R2UploadPartOptions, ) => { - let body: Buffer | Uint8Array | string; - - if (value instanceof globalThis.ReadableStream) { - const reader = value.getReader(); - const chunks: Uint8Array[] = []; - while (true) { - const { done, value: chunk } = await reader.read(); - if (done) break; - chunks.push(chunk); - } - body = Buffer.concat(chunks); - } else if (value instanceof ArrayBuffer) { - body = new Uint8Array(value); - } else if (ArrayBuffer.isView(value)) { - body = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } else if (typeof value === 'string') { - body = value; - } else if (value instanceof globalThis.Blob) { - body = new Uint8Array(await value.arrayBuffer()); - } else { - throw new Error('Unsupported value type'); - } + const body = await this.convertBodyToUploadable(value); const uploadCommand = new UploadPartCommand({ Bucket: this.bucketName, @@ -521,20 +548,48 @@ export class R2BucketService { const completeResponse = await this.s3Client.send(completeCommand); - return this.createR2Object(key, completeResponse); + return this.createR2Object(key, { ...completeResponse }); }, }; } + private async convertBodyToUploadable( + value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, + ): Promise { + if (value instanceof globalThis.ReadableStream) { + const reader = value.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (typeof value === 'string') { + return value; + } + if (value instanceof globalThis.Blob) { + return new Uint8Array(await value.arrayBuffer()); + } + throw new Error('Unsupported value type'); + } + private createR2Object(key: string, response: Record): R2Object { const r2Object = { key, version: response.VersionId || '', size: response.ContentLength || 0, - etag: response.ETag?.replace(/"/g, '') || '', + etag: isString(response.ETag) ? response.ETag.replace(/"/g, '') : '', httpEtag: response.ETag || '', checksums: this.createChecksums(response), - uploaded: new Date(response.LastModified || Date.now()), + uploaded: new Date(String(response.LastModified) || new Date()), httpMetadata: { contentType: response.ContentType, contentLanguage: response.ContentLanguage, @@ -604,7 +659,8 @@ export class R2BucketService { return `bytes=-${range.suffix}`; } - const { offset = 0, length } = range; + const offset = 'offset' in range ? (range.offset ?? 0) : 0; + const length = 'length' in range ? range.length : undefined; if (length !== undefined) { return `bytes=${offset}-${offset + length - 1}`; } @@ -627,10 +683,16 @@ export class R2BucketService { contentDisposition: metadata.get('content-disposition') || undefined, contentEncoding: metadata.get('content-encoding') || undefined, cacheControl: metadata.get('cache-control') || undefined, - cacheExpiry: metadata.get('expires') ? new Date(metadata.get('expires')) : undefined, + cacheExpiry: metadata.get('expires') + ? new Date(String(metadata.get('expires'))) + : undefined, }; } - return metadata; + // Return the metadata object if it matches the R2HTTPMetadata interface + if (isR2HTTPMetadata(metadata)) { + return metadata; + } + return undefined; } } diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index b50caa0734..cb9f1db7da 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -1,4 +1,4 @@ -import type { Env } from '@packrat/api/utils/env-validation'; +import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; import type { Context } from 'hono'; diff --git a/packages/api/src/types/env.ts b/packages/api/src/types/env.ts index 2447fb4323..d84c25ecad 100644 --- a/packages/api/src/types/env.ts +++ b/packages/api/src/types/env.ts @@ -1 +1 @@ -export type { Env } from '@packrat/api/utils/env-validation'; +export type { ValidatedEnv as Env } from '@packrat/api/utils/env-validation'; diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 0b7c4b96ab..05b29f9d7c 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; // --- User Schema --- export const UserSchema = z.object({ - id: z.string(), + id: z.number().int().positive(), name: z.string(), email: z.string().email(), avatar: z.string().url(), @@ -69,36 +69,70 @@ export type ItemReview = { verified?: boolean; }; -export type CatalogItem = { - id: string; - name: string; - description: string; - defaultWeight: number; - weightUnit: string; - category: string; - image: string; - createdAt: string; - updatedAt: string; - usageCount?: number; - // Enhanced properties - brand?: string; - ratingValue?: number; - productUrl?: string; - color?: string | null; - size?: string | null; - sku?: string; - price?: number | null; - availability?: string; - seller?: string; - productSku?: string; - material?: string; - currency?: string; - condition?: string; - techs?: Record; - // New properties - links?: ItemLink[]; - reviews?: ItemReview[]; -}; +// --- Catalog Item Schema --- +export const CatalogItemSchema = z.object({ + id: z.number().int().positive(), + name: z.string(), + productUrl: z.string(), + sku: z.string(), + weight: z.number().nonnegative(), + weightUnit: z.string(), + description: z.string().optional(), + categories: z.array(z.string()).optional(), + images: z.array(z.string()).optional(), + brand: z.string().optional(), + model: z.string().optional(), + ratingValue: z.number().optional(), + color: z.string().optional(), + size: z.string().optional(), + price: z.number().optional(), + availability: z.enum(['in_stock', 'out_of_stock', 'preorder']).optional(), + seller: z.string().optional(), + productSku: z.string().optional(), + material: z.string().optional(), + currency: z.string().optional(), + condition: z.string().optional(), + reviewCount: z.number().int().optional(), + variants: z + .array( + z.object({ + attribute: z.string(), + values: z.array(z.string()), + }), + ) + .optional(), + techs: z.record(z.string(), z.string()).optional(), + links: z + .array( + z.object({ + title: z.string(), + url: z.string(), + }), + ) + .optional(), + reviews: z + .array( + z.object({ + user_name: z.string(), + user_avatar: z.string().optional(), + context: z.record(z.string(), z.string()).optional(), + recommends: z.boolean().optional(), + rating: z.number(), + title: z.string(), + text: z.string(), + date: z.string(), + images: z.array(z.string()).optional(), + upvotes: z.number().optional(), + downvotes: z.number().optional(), + verified: z.boolean().optional(), + }), + ) + .optional(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export type CatalogItem = z.infer; // --- Pack Item Schema --- export const PackItemSchema = z.object({ id: z.string(), @@ -113,10 +147,10 @@ export const PackItemSchema = z.object({ image: z.string().url().optional(), notes: z.string().optional(), packId: z.string(), - catalogItemId: z.string().optional(), // Reference to original catalog item + catalogItemId: z.number().int().positive().optional(), // Reference to original catalog item createdAt: z.string().datetime(), updatedAt: z.string().datetime(), - userId: z.string(), + userId: z.number().int().positive(), }); export type PackItem = z.infer; @@ -130,7 +164,7 @@ export const PackSchema = z.object({ baseWeight: z.number().nonnegative().optional(), // Weight without consumables (computed) totalWeight: z.number().nonnegative().optional(), // Total weight including consumables (computed) items: z.array(PackItemSchema).optional(), - userId: z.string(), + userId: z.number().int().positive(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), isPublic: z.boolean(), diff --git a/packages/api/src/types/variables.ts b/packages/api/src/types/variables.ts index 9e0da7bbc8..80d6ade8b9 100644 --- a/packages/api/src/types/variables.ts +++ b/packages/api/src/types/variables.ts @@ -1,6 +1,6 @@ export type Variables = { user: { - id: number; + userId: number; role: 'USER' | 'ADMIN'; }; }; diff --git a/packages/api/src/utils/DbUtils.ts b/packages/api/src/utils/DbUtils.ts index 5dc47de619..290096fd93 100644 --- a/packages/api/src/utils/DbUtils.ts +++ b/packages/api/src/utils/DbUtils.ts @@ -1,6 +1,6 @@ import { createDb } from '@packrat/api/db'; import { catalogItems, packs } from '@packrat/api/db/schema'; -import { eq, inArray, sql } from 'drizzle-orm'; +import { and, arrayOverlaps, eq, inArray, type SQL, sql } from 'drizzle-orm'; import type { Context } from 'hono'; // Get pack details from the database @@ -35,18 +35,26 @@ export async function getCatalogItems({ c: Context; }) { const db = createDb(c); - let query = db.select().from(catalogItems); + const filters: SQL[] = []; + + // For categories, use Drizzle's arrayOverlaps operator for JSONB arrays if (options?.categories?.length) { - query = query.where(inArray(catalogItems.category, options.categories)); + filters.push(arrayOverlaps(catalogItems.categories, options.categories)); } + // For IDs, we can use the standard inArray if (options?.ids?.length) { - query = query.where(inArray(catalogItems.id, options.ids)); + filters.push(inArray(catalogItems.id, options.ids)); } + const query = db + .select() + .from(catalogItems) + .where(filters.length > 0 ? and(...filters) : undefined); + if (options?.limit) { - query = query.limit(options.limit); + return query.limit(options.limit); } return query; diff --git a/packages/api/src/utils/ai/provider.ts b/packages/api/src/utils/ai/provider.ts index 96041074ee..c7d4cfcccb 100644 --- a/packages/api/src/utils/ai/provider.ts +++ b/packages/api/src/utils/ai/provider.ts @@ -1,6 +1,7 @@ import type { OpenAIProvider } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai'; -import type { createWorkersAI } from 'workers-ai-provider'; +import type { Env } from '@packrat/api/types/env'; +// import type { createWorkersAI } from 'workers-ai-provider'; export type AIProvider = 'openai' | 'cloudflare-workers-ai'; @@ -8,6 +9,7 @@ interface BaseProviderConfig { openAiApiKey: string; cloudflareAccountId: string; cloudflareGatewayId: string; + cloudflareAiBinding: Env['AI']; } interface OpenAIProviderConfig extends BaseProviderConfig { @@ -21,10 +23,12 @@ interface WorkersAIProviderConfig extends BaseProviderConfig { export type AIProviderConfig = OpenAIProviderConfig | WorkersAIProviderConfig; // Define return type for Workers AI -type WorkersAIProvider = ReturnType; +// type WorkersAIProvider = ReturnType; + +type CreateAIProviderReturn = OpenAIProvider; // Function to create an AI provider based on the config -export function createAIProvider(config: AIProviderConfig): OpenAIProvider | WorkersAIProvider { +export function createAIProvider(config: AIProviderConfig): CreateAIProviderReturn { const { openAiApiKey, provider, cloudflareAccountId, cloudflareGatewayId } = config; // All providers go through Cloudflare Gateway if configured diff --git a/packages/api/src/utils/api-middleware.ts b/packages/api/src/utils/api-middleware.ts deleted file mode 100644 index b30d936659..0000000000 --- a/packages/api/src/utils/api-middleware.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { User } from '@packrat/api/db/schema'; -import { getEnv } from '@packrat/api/utils/env-validation'; -import type { Context } from 'hono'; -import { verifyJWT } from './auth'; - -export async function authenticateRequest( - c: Context, -): Promise<{ userId: User['id']; role: User['role'] } | null> { - const authHeader = c.req.header('Authorization'); - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return null; - } - - const token = authHeader.split(' ')[1]; - - if (!token) { - return null; - } - - const payload = await verifyJWT({ token, c }); - - if (!payload) { - return null; - } - - return { userId: payload.userId, role: payload.role }; -} - -export function unauthorizedResponse() { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); -} - -export function isValidApiKey(c: Context): boolean { - const apiKeyHeader = c.req.header('X-API-Key'); - if (!apiKeyHeader) return false; - // Get env - // Type assertion is safe because Context is typed for Env - const { PACKRAT_API_KEY } = getEnv(c); - if (!PACKRAT_API_KEY) return false; - return apiKeyHeader === PACKRAT_API_KEY; -} diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index 85c3b274ed..ed8e17981e 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -106,3 +106,12 @@ export function validateEmail(email: string): boolean { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email); } + +// Validate API key +export function isValidApiKey(c: Context): boolean { + const apiKeyHeader = c.req.header('X-API-Key'); + if (!apiKeyHeader) return false; + const { PACKRAT_API_KEY } = getEnv(c); + if (!PACKRAT_API_KEY) return false; + return apiKeyHeader === PACKRAT_API_KEY; +} diff --git a/packages/api/src/utils/csv-utils.ts b/packages/api/src/utils/csv-utils.ts new file mode 100644 index 0000000000..a9d95c07ed --- /dev/null +++ b/packages/api/src/utils/csv-utils.ts @@ -0,0 +1,286 @@ +import type { NewCatalogItem } from '../db/schema'; + +export function mapCsvRowToItem({ + values, + fieldMap, +}: { + values: string[]; + fieldMap: Record; +}): Partial | null { + const item: Partial = {}; + // --- Optional Scalars --- + item.description = + fieldMap.description !== undefined + ? values[fieldMap.description]?.replace(/[\r\n]+/g, ' ').trim() + : undefined; + + const name = fieldMap.name !== undefined ? values[fieldMap.name]?.trim() : undefined; + item.name = name; + + const productUrl = + fieldMap.productUrl !== undefined ? values[fieldMap.productUrl]?.trim() : undefined; + item.productUrl = productUrl; + + const currency = fieldMap.currency !== undefined ? values[fieldMap.currency]?.trim() : undefined; + item.currency = currency; + + const reviewCountStr = + fieldMap.reviewCount !== undefined ? values[fieldMap.reviewCount] : undefined; + item.reviewCount = reviewCountStr ? parseInt(reviewCountStr) || 0 : 0; + + if (fieldMap.categories !== undefined && values[fieldMap.categories]) { + const val = values[fieldMap.categories].trim(); + try { + item.categories = val.startsWith('[') + ? JSON.parse(val) + : val + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + } catch { + item.categories = val ? [val] : undefined; + } + } else { + item.categories = undefined; + } + + let images: string[] | undefined; + if (fieldMap.images !== undefined && values[fieldMap.images]) { + try { + const val = values[fieldMap.images].trim(); + images = val.startsWith('[') + ? JSON.parse(val) + : val + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + } catch { + images = undefined; + } + } else { + images = undefined; + } + item.images = images; + + // Scalars + const weightStr = fieldMap.weight !== undefined ? values[fieldMap.weight] : undefined; + const unitStr = fieldMap.weightUnit !== undefined ? values[fieldMap.weightUnit] : undefined; + if (weightStr && parseFloat(weightStr) > 0) { + const { weight, unit } = parseWeight(weightStr, unitStr); + item.weight = weight || undefined; + item.weightUnit = unit || undefined; + } + + const priceStr = fieldMap.price !== undefined ? values[fieldMap.price] : undefined; + if (priceStr) item.price = parsePrice(priceStr); + + const ratingStr = fieldMap.ratingValue !== undefined ? values[fieldMap.ratingValue] : undefined; + if (ratingStr) item.ratingValue = parseFloat(ratingStr) || null; + + if (fieldMap.variants !== undefined && values[fieldMap.variants]) { + const val = values[fieldMap.variants].trim(); + try { + item.variants = JSON.parse(val); + } catch { + try { + item.variants = JSON.parse(val.replace(/'/g, '"')); + } catch { + item.variants = []; + } + } + } + + if (fieldMap.faqs !== undefined && values[fieldMap.faqs]) { + const val = values[fieldMap.faqs].trim(); + try { + item.faqs = parseFaqs(val); + } catch { + item.faqs = []; + } + } + + // JSON fields + const jsonFields: Extract<'links' | 'reviews' | 'qas', keyof NewCatalogItem>[] = [ + 'links', + 'reviews', + 'qas', + ]; + for (const field of jsonFields) { + if (fieldMap[field as string] !== undefined && values[fieldMap[field as string]]) { + try { + item[field] = safeJsonParse(values[fieldMap[field as string]]); + } catch { + item[field] = []; + } + } + } + + // Techs + fallback for weight + const techsStr = fieldMap.techs !== undefined ? values[fieldMap.techs] : undefined; + if (techsStr) { + try { + const parsed = safeJsonParse>(techsStr); + item.techs = Array.isArray(parsed) ? {} : parsed; + + if (!item.weight && !Array.isArray(parsed)) { + const claimedWeight = parsed['Claimed Weight'] || parsed.weight; + if (claimedWeight) { + const { weight, unit } = parseWeight(claimedWeight); + item.weight = weight || undefined; + item.weightUnit = unit || undefined; + } + } + } catch { + item.techs = {}; + } + } + + // Direct mappings for string fields + const stringFields = [ + 'brand', + 'model', + 'color', + 'size', + 'sku', + 'productSku', + 'seller', + 'material', + 'condition', + ] as const; + for (const field of stringFields) { + const index = fieldMap[field]; + if (index !== undefined && values[index]) { + item[field] = values[index].replace(/^"|"$/g, '').trim(); + } + } + + // Handle availability enum separately + if (fieldMap.availability !== undefined && values[fieldMap.availability]) { + item.availability = values[fieldMap.availability] + .replace(/^"|"$/g, '') + .trim() as NewCatalogItem['availability']; + } + + return item; +} + +export function parseWeight( + weightStr: string, + unitStr?: string, +): { weight: number | null; unit: string | null } { + if (!weightStr) return { weight: null, unit: null }; + + const weightVal = parseFloat(weightStr); + if (Number.isNaN(weightVal) || weightVal < 0) { + return { weight: null, unit: null }; + } + + const hint = (unitStr || weightStr).toLowerCase(); + + if (hint.includes('oz')) { + return { weight: Math.round(weightVal * 28.35), unit: 'oz' }; + } + if (hint.includes('lb')) { + return { weight: Math.round(weightVal * 453.592), unit: 'lb' }; + } + if (hint.includes('kg')) { + return { weight: weightVal * 1000, unit: 'kg' }; + } + + return { weight: weightVal, unit: 'g' }; +} + +/** + * Normalizes a messy JSON-like string to make it more parseable by JSON.parse. + * Handles Python values, smart quotes, invalid escapes, trailing commas, and more. + */ +export function normalizeJsonString(value: string): string { + return ( + value + .trim() + + // Replace Python-style null/booleans with JS equivalents + .replace(/\bNone\b/g, 'null') + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + + // Normalize smart/special quotes to standard quotes + .replace(/[β€˜β€™β€›β€Ήβ€Ί]/g, "'") + .replace(/[β€œβ€β€žβ€ŸΒ«Β»]/g, '"') + .replace(/[`]/g, '') + + // Convert object keys from 'key': to "key": + .replace(/([{,]\s*)'([^']+?)'\s*:/g, '$1"$2":') + + // Convert string values from 'value' to "escaped value" + .replace(/:\s*'(.*?)'(?=\s*[},])/g, (_, val) => { + const escaped = val + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\\n|\\r|\\b|\\t|\\f|\r?\n|\r|\b|\t|\f/g, '') // Remove newlines/control chars + .replace(/\u2028|\u2029/g, ''); // Remove special Unicode line separators + return `: "${escaped}"`; + }) + + // Decode \xNN hex escapes to characters + .replace(/\\x([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + + // Escape lone backslashes (e.g., \ not followed by valid escape) + .replace(/([^\\])\\(?![\\/"'bfnrtu])/g, '$1\\\\') + + // Remove trailing commas before closing braces/brackets + .replace(/,\s*([}\]])/g, '$1') + ); +} + +export function safeJsonParse(value: string): T | [] { + if (!value || value === 'undefined' || value === 'null') return []; + + const normalized = normalizeJsonString(value); + + try { + return JSON.parse(normalized) as T; + } catch (err) { + console.warn('❌ Failed to parse JSON:', { + error: err, + originalInput: value, + normalizedInput: normalized, + }); + return []; + } +} + +export function parseFaqs(input: string): Array<{ question: string; answer: string }> { + if (!input || typeof input !== 'string') return []; + + const results: Array<{ question: string; answer: string }> = []; + + // Remove outer quotes + let cleaned = input.trim(); + if (cleaned.startsWith('"') && cleaned.endsWith('"')) { + cleaned = cleaned.slice(1, -1).replace(/\\"/g, '"'); + } + + // Replace smart quotes + cleaned = normalizeJsonString(cleaned); + + // Use a global regex to extract each question-answer block + const regex = + /{[^{}]*?['"]question['"]\s*:\s*['"](.+?)['"]\s*,\s*['"]answer['"]\s*:\s*['"](.+?)['"]\s*}/g; + + let match = regex.exec(cleaned); + while (match !== null) { + const question = match[1].trim(); + const answer = match[2].trim(); + results.push({ question, answer }); + + match = regex.exec(cleaned); + } + return results; +} + +export function parsePrice(priceStr: string): number | null { + if (!priceStr) return null; + const price = parseFloat(priceStr.replace(/[^0-9.]/g, '')); + return Number.isNaN(price) ? null : price; +} diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts index 31bf5161ef..3f25d8f7ba 100644 --- a/packages/api/src/utils/env-validation.ts +++ b/packages/api/src/utils/env-validation.ts @@ -80,7 +80,7 @@ const testEnvSchema = apiEnvSchema.partial().extend({ type ValidatedAppEnv = z.infer; // Override Cloudflare binding types with proper TypeScript types -export type Env = Omit< +export type ValidatedEnv = Omit< ValidatedAppEnv, | 'CF_VERSION_METADATA' | 'AI' @@ -103,14 +103,15 @@ export type Env = Omit< }; // Cache for validated environments per request -const envCache = new WeakMap(); +const envCache = new WeakMap(); // Check if we're in a test environment function isTestEnvironment(): boolean { return ( process.env.NODE_ENV === 'test' || process.env.VITEST === 'true' || - (typeof global !== 'undefined' && (global as Record).__vitest__ !== undefined) + (typeof global !== 'undefined' && + (global as unknown as { __vitest__?: unknown }).__vitest__ !== undefined) ); } @@ -118,7 +119,7 @@ function isTestEnvironment(): boolean { * Get and validate environment variables from Hono context * Results are cached per request context */ -export function getEnv(c: Context): Env { +export function getEnv(c: Context): ValidatedEnv { // Check if we already have validated env for this context const cached = envCache.get(c); if (cached) { @@ -126,7 +127,7 @@ export function getEnv(c: Context): Env { } // Get raw environment - const rawEnv = env(c); + const rawEnv = env(c); // Use relaxed validation for test environments const schema = isTestEnvironment() ? testEnvSchema : apiEnvSchema; @@ -139,7 +140,7 @@ export function getEnv(c: Context): Env { } // Merge validated data with correctly typed Cloudflare bindings from rawEnv - const data: Env = { + const data: ValidatedEnv = { ...validated.data, CF_VERSION_METADATA: rawEnv.CF_VERSION_METADATA || validated.data.CF_VERSION_METADATA, AI: rawEnv.AI || validated.data.AI, @@ -149,7 +150,7 @@ export function getEnv(c: Context): Env { ETL_QUEUE: rawEnv.ETL_QUEUE || validated.data.ETL_QUEUE, LOGS_QUEUE: rawEnv.LOGS_QUEUE || validated.data.LOGS_QUEUE, EMBEDDINGS_QUEUE: rawEnv.EMBEDDINGS_QUEUE || validated.data.EMBEDDINGS_QUEUE, - }; + } as ValidatedEnv; // Cache the result envCache.set(c, data); diff --git a/packages/api/src/utils/itemCalculations.ts b/packages/api/src/utils/itemCalculations.ts index 84be85dde5..c2b50c89d3 100644 --- a/packages/api/src/utils/itemCalculations.ts +++ b/packages/api/src/utils/itemCalculations.ts @@ -14,7 +14,7 @@ export function getEffectiveWeight(item: CatalogItem | PackItem): number { if (isPackItem(item)) { return item.weight; } - return 'defaultWeight' in item ? item.defaultWeight : 0; + return 'weight' in item ? (item.weight as number) : 0; } /** diff --git a/packages/api/src/utils/openapi.ts b/packages/api/src/utils/openapi.ts new file mode 100644 index 0000000000..5ef75d8707 --- /dev/null +++ b/packages/api/src/utils/openapi.ts @@ -0,0 +1,285 @@ +import type { OpenAPIHono } from '@hono/zod-openapi'; +import type { Env } from '@packrat/api/types/env'; +import type { Variables } from '@packrat/api/types/variables'; + +export const configureOpenAPI = (app: OpenAPIHono<{ Bindings: Env; Variables: Variables }>) => { + // Register components + app.openAPIRegistry.registerComponent('securitySchemes', 'bearerAuth', { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT token obtained from /api/auth/login or /api/auth/refresh endpoints', + }); + + app.openAPIRegistry.registerComponent('securitySchemes', 'refreshToken', { + type: 'apiKey', + in: 'header', + name: 'X-Refresh-Token', + description: 'Refresh token for obtaining new access tokens', + }); + + app.openAPIRegistry.registerComponent('schemas', 'Error', { + type: 'object', + properties: { + error: { + type: 'string', + description: 'Error message', + }, + code: { + type: 'string', + description: 'Error code for programmatic handling', + }, + details: { + type: 'object', + description: 'Additional error details', + }, + }, + required: ['error'], + }); + + app.openAPIRegistry.registerComponent('schemas', 'Success', { + type: 'object', + properties: { + success: { + type: 'boolean', + example: true, + }, + message: { + type: 'string', + description: 'Success message', + }, + }, + required: ['success'], + }); + + app.openAPIRegistry.registerComponent('schemas', 'PaginationParams', { + type: 'object', + properties: { + page: { + type: 'integer', + minimum: 1, + default: 1, + description: 'Page number', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 20, + description: 'Items per page', + }, + sortBy: { + type: 'string', + description: 'Field to sort by', + }, + sortOrder: { + type: 'string', + enum: ['asc', 'desc'], + default: 'asc', + description: 'Sort order', + }, + }, + }); + + app.openAPIRegistry.registerComponent('schemas', 'PaginationResponse', { + type: 'object', + properties: { + total: { + type: 'integer', + description: 'Total number of items', + }, + page: { + type: 'integer', + description: 'Current page number', + }, + limit: { + type: 'integer', + description: 'Items per page', + }, + totalPages: { + type: 'integer', + description: 'Total number of pages', + }, + hasNext: { + type: 'boolean', + description: 'Whether there is a next page', + }, + hasPrev: { + type: 'boolean', + description: 'Whether there is a previous page', + }, + }, + }); + + app.openAPIRegistry.registerComponent('responses', 'UnauthorizedError', { + description: 'Authentication required', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Authentication required', + code: 'UNAUTHORIZED', + }, + }, + }, + }); + + app.openAPIRegistry.registerComponent('responses', 'ForbiddenError', { + description: 'Insufficient permissions', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Insufficient permissions to perform this action', + code: 'FORBIDDEN', + }, + }, + }, + }); + + app.openAPIRegistry.registerComponent('responses', 'NotFoundError', { + description: 'Resource not found', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Resource not found', + code: 'NOT_FOUND', + }, + }, + }, + }); + + app.openAPIRegistry.registerComponent('responses', 'ValidationError', { + description: 'Validation error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'Validation failed', + code: 'VALIDATION_ERROR', + details: { + field: 'email', + message: 'Invalid email format', + }, + }, + }, + }, + }); + + app.openAPIRegistry.registerComponent('responses', 'ServerError', { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + example: { + error: 'An unexpected error occurred', + code: 'INTERNAL_ERROR', + }, + }, + }, + }); + + app.doc('/doc', { + openapi: '3.1.0', + info: { + title: 'PackRat API', + version: '1.0.0', + description: + 'PackRat is a comprehensive outdoor adventure planning platform that helps users organize and manage their packing lists for trips.', + contact: { + name: 'PackRat Support', + email: 'support@packrat.app', + url: 'https://packrat.app', + }, + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + servers: [ + { + url: 'https://api.packrat.app', + description: 'Production server', + }, + { + url: 'https://staging-api.packrat.app', + description: 'Staging server', + }, + { + url: 'http://localhost:8787', + description: 'Local development server', + }, + ], + tags: [ + { + name: 'Authentication', + description: 'User authentication and authorization endpoints', + externalDocs: { + description: 'Learn more about authentication', + url: 'https://docs.packrat.app/auth', + }, + }, + { + name: 'Users', + description: 'User profile and account management', + }, + { + name: 'Packs', + description: 'Pack creation, management, and sharing', + }, + { + name: 'Pack Items', + description: 'Manage items within packs', + }, + { + name: 'Pack Templates', + description: 'Pre-built pack templates for common activities', + }, + { + name: 'Catalog', + description: 'Product catalog with gear information and recommendations', + }, + { + name: 'Guides', + description: 'Adventure guides and location information', + }, + { + name: 'Search', + description: 'Search functionality across the platform', + }, + { + name: 'Weather', + description: 'Weather information for trip planning', + }, + { + name: 'Chat', + description: 'AI-powered chat assistant for trip planning', + }, + { + name: 'Admin', + description: 'Administrative endpoints (restricted access)', + }, + { + name: 'Upload', + description: 'File upload and media management', + }, + ], + 'x-logo': { + url: 'https://packrat.app/logo.png', + altText: 'PackRat Logo', + }, + }); + + return app; +}; diff --git a/packages/api/test/setup.ts b/packages/api/test/setup.ts index dc46a74da8..2d3b6a03f1 100644 --- a/packages/api/test/setup.ts +++ b/packages/api/test/setup.ts @@ -42,7 +42,6 @@ process.env.PACKRAT_SCRAPY_BUCKET_R2_BUCKET_NAME = 'test-scrapy-bucket'; process.env.PACKRAT_GUIDES_RAG_NAME = 'test-rag'; process.env.PACKRAT_GUIDES_BASE_URL = 'https://guides.test.com'; -import { $ } from 'bun'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Client } from 'pg'; import { afterAll, beforeAll, beforeEach, vi } from 'vitest'; @@ -51,26 +50,23 @@ import * as schema from '../src/db/schema'; let testClient: Client; let testDb: ReturnType; -// Setup PostgreSQL Docker container before all tests -beforeAll(async () => { - console.log('🐳 Starting PostgreSQL Docker container for tests...'); +vi.mock('hono/adapter', async () => { + const actual = await vi.importActual('hono/adapter'); + return { ...actual, env: () => process.env }; +}); - // Start Docker Compose with PostgreSQL container - try { - const result = await $`docker compose -f docker-compose.test.yml up -d --wait`; - if (result.exitCode !== 0) { - throw new Error(`Docker compose failed with code ${result.exitCode}`); - } - console.log('βœ… PostgreSQL container started successfully'); - } catch (error) { - console.error('❌ Failed to start PostgreSQL container:', error); - throw error; - } +// Mock the database module to use our test database (node-postgres version) +vi.mock('@packrat/api/db', () => ({ + createDb: vi.fn(() => testDb), + createReadOnlyDb: vi.fn(() => testDb), + createDbClient: vi.fn(() => testDb), +})); - // Wait a bit for the database to be fully ready - await new Promise((resolve) => setTimeout(resolve, 3000)); +// Setup PostgreSQL connection for tests +beforeAll(async () => { + console.log('πŸ”§ Setting up test database connection...'); - // Create direct PostgreSQL client connection + // Create direct PostgreSQL client connection for manual database operations testClient = new Client({ host: 'localhost', port: 5433, @@ -79,32 +75,41 @@ beforeAll(async () => { password: 'test_password', }); - await testClient.connect(); - testDb = drizzle(testClient, { schema }); - - // Run migrations using direct PostgreSQL client try { + await testClient.connect(); + testDb = drizzle(testClient, { schema }) as ReturnType; + console.log('βœ… Test database connected successfully'); + + // Run migrations using direct PostgreSQL client const fs = await import('node:fs/promises'); const path = await import('node:path'); const migrationsDir = path.join(process.cwd(), 'drizzle'); - const files = await fs.readdir(migrationsDir); - const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort(); - - for (const file of sqlFiles) { - const migrationSql = await fs.readFile(path.join(migrationsDir, file), 'utf-8'); - await testClient.query(migrationSql); + try { + const files = await fs.readdir(migrationsDir); + const sqlFiles = files.filter((f) => f.endsWith('.sql')).sort(); + + for (const file of sqlFiles) { + const migrationSql = await fs.readFile(path.join(migrationsDir, file), 'utf-8'); + await testClient.query(migrationSql); + } + + console.log('βœ… Test database migrations completed'); + } catch (error) { + console.error('❌ Failed to run database migrations:', error); + throw error; } - - console.log('βœ… Test database migrations completed'); } catch (error) { - console.error('❌ Failed to run database migrations:', error); + console.error('❌ Failed to connect to test database:', error); + console.log('This is expected in CI where PostgreSQL service should be available'); throw error; } }); // Clean up database after each test to ensure isolation beforeEach(async () => { + if (!testClient) return; + // Truncate all tables except migrations and drizzle metadata using PostgreSQL client const tablesToTruncate = [ 'users', @@ -131,33 +136,15 @@ beforeEach(async () => { // Cleanup after all tests afterAll(async () => { - console.log('🧹 Cleaning up test database and PostgreSQL Docker container...'); + console.log('🧹 Cleaning up test database connection...'); try { // Close PostgreSQL client connection if (testClient) { await testClient.end(); } - - // Stop and remove Docker Compose containers - const result = await $`docker compose -f docker-compose.test.yml down -v`; - if (result.exitCode !== 0) { - throw new Error(`Docker compose down failed with code ${result.exitCode}`); - } - console.log('βœ… PostgreSQL container stopped and cleaned up'); + console.log('βœ… Test database connection closed'); } catch (error) { - console.error('❌ Failed to cleanup PostgreSQL container:', error); + console.error('❌ Failed to cleanup test database connection:', error); } }); - -// Mock the database module to use our test database (node-postgres version) -vi.mock('@packrat/api/db', () => ({ - createDb: vi.fn(() => testDb), - createReadOnlyDb: vi.fn(() => testDb), - createDbClient: vi.fn(() => testDb), -})); - -vi.mock('hono/adapter', async () => { - const actual = await vi.importActual('hono/adapter'); - return { ...actual, env: () => process.env }; -}); diff --git a/packages/api/test/utils/test-helpers.ts b/packages/api/test/utils/test-helpers.ts index 81d675f896..0cd88630f7 100644 --- a/packages/api/test/utils/test-helpers.ts +++ b/packages/api/test/utils/test-helpers.ts @@ -35,7 +35,11 @@ export const api = (path: string, init?: RequestInit) => app.fetch(new Request(`http://localhost/api${path}`, init)); // Helper to create requests with authentication token -export const apiWithAuth = async (path: string, init?: RequestInit, user = TEST_USER) => { +export const apiWithAuth = async ( + path: string, + init?: RequestInit, + user: typeof TEST_USER | typeof TEST_ADMIN = TEST_USER, +) => { const token = await sign({ userId: user.id, role: user.role }, 'secret'); return app.fetch( new Request(`http://localhost/api${path}`, { diff --git a/packages/api/test/weather.test.ts b/packages/api/test/weather.test.ts index 1a8f0d3df4..ae38a7b2b2 100644 --- a/packages/api/test/weather.test.ts +++ b/packages/api/test/weather.test.ts @@ -18,11 +18,6 @@ describe('Weather Routes', () => { expectUnauthorized(res); }); - it('GET /weather/current requires auth', async () => { - const res = await api('/weather/current?lat=40.7128&lon=-74.0060'); - expectUnauthorized(res); - }); - it('GET /weather/forecast requires auth', async () => { const res = await api('/weather/forecast?lat=40.7128&lon=-74.0060'); expectUnauthorized(res); @@ -40,7 +35,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/search?q=test'); expect(res.status).toBe(200); @@ -67,7 +62,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/search?q=nonexistentplace'); expect(res.status).toBe(200); @@ -94,7 +89,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth(`/weather/search?q=${encodeURIComponent('SΓ£o Paulo')}`); expect(res.status).toBe(200); @@ -131,7 +126,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?lat=40.7128&lon=-74.0060'); expect(res.status).toBe(200); @@ -193,7 +188,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?location=London'); @@ -233,7 +228,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/forecast?lat=47.6062&lon=-122.3321'); expect(res.status).toBe(200); @@ -264,7 +259,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/forecast?lat=40.7128&lon=-74.0060&days=5'); @@ -318,7 +313,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/alerts?lat=40.7128&lon=-74.0060'); @@ -343,7 +338,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/alerts?lat=40.7128&lon=-74.0060'); @@ -369,7 +364,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?lat=40.7128&lon=-74.0060'); @@ -386,7 +381,7 @@ describe('Weather Routes', () => { const mockFetch = vi.fn(() => Promise.reject(new Error('Request timeout'))); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?lat=40.7128&lon=-74.0060'); @@ -409,7 +404,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?lat=40.7128&lon=-74.0060'); @@ -437,7 +432,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; // Make two identical requests const res1 = await apiWithAuth('/weather/current?lat=42.3601&lon=-71.0589'); @@ -474,7 +469,7 @@ describe('Weather Routes', () => { ); const originalFetch = global.fetch; - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; const res = await apiWithAuth('/weather/current?lat=40.7128&lon=-74.0060'); diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index 61540afa19..b80311c22c 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -1,6 +1,5 @@ import { resolve } from 'node:path'; import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; -import { defineConfig } from 'vitest/config'; const bindings = { // Environment & Deployment @@ -54,21 +53,19 @@ const bindings = { Object.assign(process.env, bindings); -export default defineWorkersConfig( - defineConfig({ - resolve: { - alias: { - '@': resolve(__dirname, 'src'), - }, +export default defineWorkersConfig({ + resolve: { + alias: { + '@': resolve(__dirname, 'src'), }, - test: { - setupFiles: ['./test/setup.ts'], - pool: '@cloudflare/vitest-pool-workers', - poolOptions: { - workers: { - wrangler: { configPath: './wrangler.toml' }, - }, + }, + test: { + setupFiles: ['./test/setup.ts'], + pool: '@cloudflare/vitest-pool-workers', + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, }, }, - }), -); + }, +}); diff --git a/packages/api/wrangler.jsonc b/packages/api/wrangler.jsonc index d23492e393..7402cadf5e 100644 --- a/packages/api/wrangler.jsonc +++ b/packages/api/wrangler.jsonc @@ -13,6 +13,9 @@ "enabled": true, "head_sampling_rate": 1 }, + "limits": { + "cpu_ms": 300000 // 300,000 milliseconds = 5 minutes + }, // Environment variables are managed via: // - Production: Cloudflare dashboard // - Local development: .dev.vars file (not committed to git) @@ -39,10 +42,6 @@ "queue": "packrat-etl-queue", "binding": "ETL_QUEUE" }, - { - "queue": "packrat-logs-queue", - "binding": "LOGS_QUEUE" - }, { "queue": "packrat-embeddings-queue", "binding": "EMBEDDINGS_QUEUE" @@ -54,11 +53,6 @@ "max_batch_size": 1, "max_batch_timeout": 5 }, - { - "queue": "packrat-logs-queue", - "max_batch_size": 10, - "max_batch_timeout": 5 - }, { "queue": "packrat-embeddings-queue", "max_batch_size": 100, diff --git a/packages/ui/package.json b/packages/ui/package.json index fd584bc9a9..383fe50491 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@packrat/ui", - "version": "2.0.4", + "version": "2.0.5", "private": true, "dependencies": { "@packrat-ai/nativewindui": "1.0.8"