diff --git a/packages/app/components/card/PackCardHeader/PackCardHeader.tsx b/packages/app/components/card/PackCardHeader/PackCardHeader.tsx index 9b7996679..2b1ed6f60 100644 --- a/packages/app/components/card/PackCardHeader/PackCardHeader.tsx +++ b/packages/app/components/card/PackCardHeader/PackCardHeader.tsx @@ -3,17 +3,15 @@ import useTheme from 'app/hooks/useTheme'; import { CustomCardHeader } from '../CustomCardHeader'; import { AntDesign, MaterialIcons } from '@expo/vector-icons'; import { useAuthUser } from 'app/modules/auth'; -import { - RStack, - RIconButton, - EditableText, - DropdownComponent, -} from '@packrat/ui'; +import { CascadedDropdownComponent } from '@packrat/ui/src/CascadedDropdown'; +import { EditableText } from '@packrat/ui/src/EditableText'; +import RStack from '@packrat/ui/src/RStack'; +import RIconButton from '@packrat/ui/src/RIconButton'; import { useFetchSinglePack, useDeletePack } from 'app/modules/pack'; -import { usePackTitleInput } from './usePackTitleInput'; +import { usePackActions } from './usePackActions'; import { useRouter } from 'app/hooks/router'; import { Platform, View } from 'react-native'; -import useResponsive from 'app/hooks/useResponsive'; +import { EditPackModal } from 'app/modules/pack/components/EditPackModal'; interface PackCardHeaderProps { data: any; @@ -31,24 +29,20 @@ export const PackCardHeader = ({ data, title }: PackCardHeaderProps) => { const { handleDeletePack } = useDeletePack(data.id); const { handleActionsOpenChange, - handleEdit, handleSaveTitle, - isEditMode, - isOpen, - setIsOpen, - } = usePackTitleInput(data); + isEditModalOpen, + setIsEditModalOpen, + isTitleEditMode, + } = usePackActions({ data, refetch }); const { isDark } = useTheme(); const router = useRouter(); const optionValues: optionValues[] = [ { label: 'Edit', value: 'Edit' }, - { label: 'Save', value: 'Save' }, { label: 'Delete', value: 'Delete' }, ]; - const { xxs, xs, xxl } = useResponsive(); - return ( <> { @@ -99,7 +93,7 @@ export const PackCardHeader = ({ data, title }: PackCardHeaderProps) => { maxWidth: 100, }} > - handleActionsOpenChange(value)} @@ -107,7 +101,9 @@ export const PackCardHeader = ({ data, title }: PackCardHeaderProps) => { } - style={{ paddingTop: 20 }} + style={{ + height: 20, + }} /> } native={true} @@ -116,6 +112,14 @@ export const PackCardHeader = ({ data, title }: PackCardHeaderProps) => { ) } /> + { + setIsEditModalOpen(false); + }} + refetch={refetch} + /> ); }; diff --git a/packages/app/components/card/PackCardHeader/usePackActions.ts b/packages/app/components/card/PackCardHeader/usePackActions.ts new file mode 100644 index 000000000..11f4c0081 --- /dev/null +++ b/packages/app/components/card/PackCardHeader/usePackActions.ts @@ -0,0 +1,42 @@ +import { useEditPack, useDeletePack } from 'app/modules/pack'; +import { useState } from 'react'; + +export const usePackActions = ({ data, refetch }) => { + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTitleEditMode, setIsTitleEditMode] = useState(false); + const { editPack } = useEditPack(); + const { handleDeletePack } = useDeletePack(data.id); + + const handleActionsOpenChange = (state) => { + switch (state) { + case 'Edit': + setIsEditModalOpen(true); + break; + case 'Delete': + handleDeletePack(); + break; + } + }; + + const handleSaveTitle = (title) => { + const packDetails = { + id: data.id, + name: title, + is_public: data.is_public, + }; + editPack(packDetails, { + onSuccess: () => { + refetch?.(); + }, + }); + setIsTitleEditMode(false); + }; + + return { + handleActionsOpenChange, + isTitleEditMode, + isEditModalOpen, + setIsEditModalOpen, + handleSaveTitle, + }; +}; diff --git a/packages/app/components/card/PackCardHeader/usePackTitleInput.ts b/packages/app/components/card/PackCardHeader/usePackTitleInput.ts deleted file mode 100644 index 146198ca4..000000000 --- a/packages/app/components/card/PackCardHeader/usePackTitleInput.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useEditPack, useDeletePack } from 'app/modules/pack'; -import { useRef, useState } from 'react'; - -export const usePackTitleInput = (data) => { - const [isEditMode, setIsEditMode] = useState(false); - const isEditModeRef = useRef(false); - const [isOpen, setIsOpen] = useState(false); - const { editPack } = useEditPack(); - const { handleDeletePack } = useDeletePack(data.id); - - const handleActionsOpenChange = (state) => { - setIsOpen(true); - if (!state && isEditModeRef.current) { - isEditModeRef.current = false; - setIsEditMode(true); - setIsOpen(true); - } - switch (state) { - case 'Edit': - handleEdit(); - break; - case 'Save': - handleSaveTitle(data.name); - break; - case 'Delete': - handleDelete(); - break; - default: - break; - } - }; - const handleEdit = () => { - isEditModeRef.current = true; - setIsOpen(false); - setIsEditMode(true); - }; - - const handleSaveTitle = (title) => { - const packDetails = { - id: data.id, - name: title, - is_public: data.is_public, - }; - setIsOpen(false); - editPack(packDetails); - setIsEditMode(false); - }; - const handleDelete = () => { - handleDeletePack(); - setIsOpen(false); - }; - - return { - handleActionsOpenChange, - isEditMode, - handleEdit, - handleSaveTitle, - isOpen, - setIsOpen, - }; -}; diff --git a/packages/app/config/trpcAxiosClient.ts b/packages/app/config/trpcAxiosClient.ts index cd690c149..700dc1cf9 100644 --- a/packages/app/config/trpcAxiosClient.ts +++ b/packages/app/config/trpcAxiosClient.ts @@ -1,7 +1,11 @@ -import axios, { AxiosResponse } from 'axios'; +import axios, { AxiosError, AxiosResponse } from 'axios'; import { toast } from 'app/utils/ToastUtils'; import { logoutAuthUser } from 'app/utils/userUtils'; import { getErrorMessageFromError } from 'app/utils/apiUtils'; +import { Storage } from 'app/utils/storage'; +import { vanillaTrpcClient } from 'app/trpc'; +import { TRPCErrorResponse } from '@trpc/server/rpc'; +import { TRPCClientError } from '@trpc/client'; const REQUESTS_TO_SKIP_SUCCESS_MESSAGE = [ 'getMe', @@ -34,21 +38,48 @@ const responseInterceptor = (response: AxiosResponse) => { return response; }; -const responseErrorInterceptor = (response: AxiosResponse) => { - if (response?.response?.data?.error?.data?.httpStatus === 401) { - logoutAuthUser(); +const responseErrorInterceptor = async ( + error: AxiosError, +) => { + const data = error?.response?.data; + const isUnauthorized = (item) => item?.error?.data?.httpStatus === 401; + // check auth error in both single or multiple objects response + const hasUnauthorizedError = Array.isArray(data) + ? data.some(isUnauthorized) + : isUnauthorized(data); + + if (data && hasUnauthorizedError) { + const refreshToken = await Storage.getItem('refreshToken'); + + if (!refreshToken) return; // user is logged out if refreshToken isn't present + + // maybe token expired? try refreshing. + try { + const tokens = await vanillaTrpcClient.refreshToken.query(refreshToken); + await Storage.setItem('token', tokens.accessToken); + await Storage.setItem('refreshToken', tokens.refreshToken); + + // rety request + error.config.headers.Authorization = 'Bearer ' + tokens.accessToken; + return await axios.request(error.config); + } catch (error) { + // refreshToken has also expired. Logout user. + if (error instanceof TRPCClientError && error.data.code == 'UNAUTHORIZED') + logoutAuthUser(); + return error; + } } if ( - response.config.method === 'get' || + error.config.method === 'get' || REQUESTS_TO_SKIP_ERROR_MESSAGE.some((url) => - response.config.url?.includes?.(url), + error.config.url?.includes?.(url), ) ) { - return response; + return error; } - const responseMessage = getErrorMessageFromError(response); + const responseMessage = getErrorMessageFromError(error); if (responseMessage) { toast({ @@ -58,7 +89,7 @@ const responseErrorInterceptor = (response: AxiosResponse) => { }); } - return response; + return error; }; axiosInstance.interceptors.response.use( diff --git a/packages/app/modules/auth/hooks/useLogin.ts b/packages/app/modules/auth/hooks/useLogin.ts index 14e9973ef..0d25e2b0f 100644 --- a/packages/app/modules/auth/hooks/useLogin.ts +++ b/packages/app/modules/auth/hooks/useLogin.ts @@ -19,8 +19,8 @@ export const useLogin = (): UseLoginReturn => { const handleLogin: UseLoginReturn['handleLogin'] = (data) => { const { email, password } = data; signIn({ email, password }) - .then((user) => { - sessionSignIn(user); + .then((tokens) => { + sessionSignIn(tokens); }) .catch(() => {}); }; diff --git a/packages/app/modules/auth/hooks/useLogout.ts b/packages/app/modules/auth/hooks/useLogout.ts index d97bd4db4..02fd19d8d 100644 --- a/packages/app/modules/auth/hooks/useLogout.ts +++ b/packages/app/modules/auth/hooks/useLogout.ts @@ -1,12 +1,20 @@ import { Storage } from 'app/utils/storage'; import { useUserSetter } from './useUserSetter'; +import { queryTrpc } from 'app/trpc'; export const useLogout = () => { const setUser = useUserSetter(); + const utils = queryTrpc.useUtils(); - const logout = () => { + const logout = async () => { setUser(null); - Storage.removeItem('token'); + try { + await utils.logout.fetch(await Storage.getItem('refreshToken')); + } catch { + // pass + } + await Storage.removeItem('token'); + await Storage.removeItem('refreshToken'); }; return logout; diff --git a/packages/app/modules/auth/hooks/useSessionSignIn.ts b/packages/app/modules/auth/hooks/useSessionSignIn.ts index 2d622db21..e7e2ce25b 100644 --- a/packages/app/modules/auth/hooks/useSessionSignIn.ts +++ b/packages/app/modules/auth/hooks/useSessionSignIn.ts @@ -7,14 +7,12 @@ export const useSessionSignIn = () => { const setUser = useUserSetter(); const router = useRouter(); - const sessionSignIn = useCallback((user) => { - if (user?.token) { - (async () => { - setUser(user); - await Storage.setItem('token', user.token); - router.push('/'); - })(); - } + const sessionSignIn = useCallback((tokens) => { + (async () => { + await Storage.setItem('token', tokens.accessToken); + await Storage.setItem('refreshToken', tokens.refreshToken); + router.push('/'); + })(); }, []); return sessionSignIn; diff --git a/packages/app/modules/auth/screens/LoginScreen.tsx b/packages/app/modules/auth/screens/LoginScreen.tsx index 54aaeb19e..9abeec2af 100644 --- a/packages/app/modules/auth/screens/LoginScreen.tsx +++ b/packages/app/modules/auth/screens/LoginScreen.tsx @@ -3,6 +3,7 @@ import useTheme from '../../../hooks/useTheme'; import { useGoogleAuth, useLogin } from 'app/modules/auth'; import { SignInScreen } from '@packrat/ui/src/Bento/forms/layouts'; import { View } from 'react-native'; +import { ScrollView } from 'react-native'; const demoUser = { email: 'zoot3@email.com', @@ -35,25 +36,30 @@ export function LoginScreen() { - - - + + + + + ); } diff --git a/packages/app/modules/auth/screens/RegisterScreen.tsx b/packages/app/modules/auth/screens/RegisterScreen.tsx index b47de9fa7..a552f4841 100644 --- a/packages/app/modules/auth/screens/RegisterScreen.tsx +++ b/packages/app/modules/auth/screens/RegisterScreen.tsx @@ -1,8 +1,10 @@ +import React from 'react'; import { View } from 'react-native'; import useTheme from 'app/hooks/useTheme'; import { useRegisterUser, useGoogleAuth } from 'app/modules/auth'; import { SignUpScreen } from '@packrat/ui/src/Bento/forms/layouts'; import { useState } from 'react'; +import { ScrollView } from 'react-native'; export function RegisterScreen() { const { currentTheme } = useTheme(); @@ -29,26 +31,29 @@ export function RegisterScreen() { - - - + + + + + ); } diff --git a/packages/app/modules/feed/hooks/useFeed.ts b/packages/app/modules/feed/hooks/useFeed.ts index 2f84a08e6..499b10a75 100644 --- a/packages/app/modules/feed/hooks/useFeed.ts +++ b/packages/app/modules/feed/hooks/useFeed.ts @@ -3,6 +3,15 @@ import { useUserPacks, useSimilarPacks } from 'app/modules/pack'; import { useUserTrips } from 'app/modules/trip'; import { useSimilarItems } from 'app/modules/item'; +interface UseFeedResult { + data: any[] | null; + isLoading: boolean; + refetch?: () => void; + setPage?: (page: number) => void; + hasMore?: boolean; + fetchNextPage?: (isInitialFetch?: boolean) => Promise; +} + export const useFeed = ({ queryString = 'Most Recent', ownerId, @@ -15,10 +24,10 @@ export const useFeed = ({ feedType: string; selectedTypes: Object; id: string; -}> = {}) => { +}> = {}): UseFeedResult => { switch (feedType) { case 'public': - return usePublicFeed(queryString, selectedTypes); + return usePublicFeed(queryString, selectedTypes); // Use the typed return from usePublicFeed case 'userPacks': return useUserPacks(ownerId || undefined, queryString); case 'userTrips': @@ -28,6 +37,6 @@ export const useFeed = ({ case 'similarItems': return useSimilarItems(id); default: - return { data: null, error: null, isLoading: true }; + return { data: null, isLoading: true }; } -}; +}; \ No newline at end of file diff --git a/packages/app/modules/feed/hooks/usePublicFeed.ts b/packages/app/modules/feed/hooks/usePublicFeed.ts index 32fcbe4ce..1bb2ab8a7 100644 --- a/packages/app/modules/feed/hooks/usePublicFeed.ts +++ b/packages/app/modules/feed/hooks/usePublicFeed.ts @@ -1,4 +1,5 @@ import { queryTrpc } from 'app/trpc'; +import { useState, useEffect } from 'react'; type DataType = { type: string; @@ -11,66 +12,90 @@ type DataType = { pack_id: string | null; owner_id: string | null; is_public: boolean | null; - // ... rest }[]; -type OptionalDataType = { - [K in keyof DataType]?: DataType[K]; -}[]; +type OptionalDataType = DataType[]; -export const usePublicFeed = (queryString, selectedTypes) => { - let data: OptionalDataType = []; - let isLoading = true; - let refetch = () => {}; - try { - const queryOptions = { - refetchOnWindowFocus: false, - keepPreviousData: true, - staleTime: 1000 * 60, // 1 min - cacheTime: 1000 * 60 * 5, // 5 min - }; - const publicPacks = queryTrpc.getPublicPacks.useQuery( - { queryBy: queryString ?? 'Favorite' }, - { - ...queryOptions, - onSuccess: (data) => - console.log('Successfully fetched public packs!', data), - onError: (error) => - console.error('Error fetching public packs:', error), - }, - ); - - const publicTrips = queryTrpc.getPublicTripsRoute.useQuery( - { queryBy: queryString ?? 'Favorite' }, - { - ...queryOptions, - enabled: publicPacks?.status === 'success', - }, - ); - - isLoading = - publicPacks?.status !== 'success' && publicTrips?.status !== 'success'; - - if (selectedTypes.pack && publicPacks?.status === 'success') - data = [ - ...data, - ...publicPacks.data.map((item) => ({ ...item, type: 'pack' })), - ]; - - if (selectedTypes.trip && publicTrips?.status === 'success') - data = [ - ...data, - ...publicTrips.data.map((item) => ({ ...item, type: 'trip' })), - ]; - - refetch = () => { - publicPacks.refetch(); - publicTrips.refetch(); +export const usePublicFeed = ( + queryString: string, + selectedTypes, + initialPage = 1, + initialLimit = 4 +) => { + const [page, setPage] = useState(initialPage); + const [data, setData] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); + + // Fetch public packs using the useQuery hook + const { + data: publicPacksData, + isLoading: isPacksLoading, + refetch: refetchPacks, + } = queryTrpc.getPublicPacks.useQuery( + { queryBy: queryString ?? 'Favorite', page, limit: initialLimit }, + { keepPreviousData: true, enabled: selectedTypes.pack } + ); + + // Fetch public trips using the useQuery hook + const { + data: publicTripsData, + isLoading: isTripsLoading, + refetch: refetchTrips, + } = queryTrpc.getPublicTripsRoute.useQuery( + { queryBy: queryString ?? 'Favorite' }, + { enabled: selectedTypes.trip && publicPacksData?.length > 0 } + ); + + // Ensure that fetching logic behaves consistently + useEffect(() => { + const processFetchedData = () => { + if (!isPacksLoading && !isTripsLoading && (publicPacksData || publicTripsData)) { + let newData: OptionalDataType = []; + + // Fetch and append packs + if (selectedTypes.pack && publicPacksData) { + newData = [...newData, ...publicPacksData.map((item) => ({ ...item, type: 'pack' }))]; + } + + // Fetch and append trips + if (selectedTypes.trip && publicTripsData) { + newData = [...newData, ...publicTripsData.map((item) => ({ ...item, type: 'trip' }))]; + } + + // Update data in state + setData((prevData) => { + return page === initialPage ? newData : [...prevData, ...newData]; // Append for subsequent pages + }); + + // Set `hasMore` based on the data fetched + setHasMore(newData.length === initialLimit); + + // Reset loading states + setIsLoading(false); + setIsFetchingNextPage(false); + } }; - } catch (error) { - console.error(error); - return { data: null, error, isLoading, refetch }; - } - return { data, error: null, isLoading, refetch }; + processFetchedData(); + }, [publicPacksData, publicTripsData, page, selectedTypes]); + + // Fetch the next page of data + const fetchNextPage = async () => { + if (hasMore && !isLoading && !isFetchingNextPage) { + setIsFetchingNextPage(true); + setPage((prevPage) => prevPage + 1); // Increment the page before fetching new data + + // Fetch packs and trips for the next page + await refetchPacks(); + if (selectedTypes.trip) { + await refetchTrips(); + } + + setIsFetchingNextPage(false); // Reset fetching state after data fetch + } + }; + + return { data, isLoading, hasMore, fetchNextPage, refetch: refetchPacks }; }; diff --git a/packages/app/modules/feed/screens/FeedScreen.tsx b/packages/app/modules/feed/screens/FeedScreen.tsx index 70c766d2e..23c2408ef 100644 --- a/packages/app/modules/feed/screens/FeedScreen.tsx +++ b/packages/app/modules/feed/screens/FeedScreen.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useState } from 'react'; -import { FlatList, View, Platform } from 'react-native'; +import React, { useMemo, useState, useEffect } from 'react'; +import { FlatList, View, Platform, ActivityIndicator } from 'react-native'; import { FeedCard, FeedSearchFilter, SearchProvider } from '../components'; import { useRouter } from 'app/hooks/router'; import { fuseSearch } from 'app/utils/fuseSearch'; @@ -23,64 +23,71 @@ const ERROR_MESSAGES = { userTrips: 'No User Trips Available', }; -interface FeedItem { - id: string; - type: string; -} - -interface SelectedTypes { - pack: boolean; - trip: boolean; -} - interface FeedProps { feedType?: string; } -interface UseFeedResult { - data: any[] | null; - error: any | null; - isLoading: boolean; - refetch: () => void; -} - const Feed = ({ feedType = 'public' }: FeedProps) => { const router = useRouter(); - const [queryString, setQueryString] = useState('Favorite'); const [selectedTypes, setSelectedTypes] = useState({ pack: true, trip: false, }); - const [selectedTrips, setSelectedTrips] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [refreshing, setRefreshing] = useState(false); + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); const user = useAuthUser(); const ownerId = user?.id; - const styles = useCustomStyles(loadStyles); - const { data, error, isLoading, refetch } = useFeed({ + + // Fetch feed data using the useFeed hook + const { data, isLoading, hasMore, fetchNextPage, refetch } = useFeed({ queryString, ownerId, feedType, selectedTypes, - }) as UseFeedResult; + }); + // Refresh data const onRefresh = () => { setRefreshing(true); - refetch(); + refetch && refetch(); // Ensure refetch is defined setRefreshing(false); }; - let arrayData = data; + // Fetch more data when reaching the end + const fetchMoreData = async () => { + // Ensure we are not already fetching and that there is more data to fetch + if (!isFetchingNextPage && hasMore && !isLoading) { + setIsFetchingNextPage(true); + await fetchNextPage(); // Call to fetch the next page + setIsFetchingNextPage(false); + } + }; + + // Web-specific scroll detection + useEffect(() => { + if (Platform.OS === 'web') { + const handleScroll = () => { + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + if (scrollTop + windowHeight >= documentHeight - 50 && !isFetchingNextPage && hasMore) { + fetchMoreData(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); // Cleanup + } + }, [isFetchingNextPage, hasMore, isLoading]); + // Filter data based on search query const filteredData = useMemo(() => { - if (!arrayData) { - return []; - } - // Fuse search + if (!data) return []; const keys = ['name', 'items.name', 'items.category']; const options = { threshold: 0.4, @@ -89,20 +96,10 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { maxPatternLength: 32, minMatchCharLength: 1, }; - - const results = fuseSearch(arrayData, searchQuery, keys, options); - - // Convert fuse results back into the format we want - // if searchQuery is empty, use the original data + const results = fuseSearch(data, searchQuery, keys, options); return searchQuery ? results.map((result) => result.item) : data; }, [searchQuery, data]); - /** - * Renders the data for the feed based on the feed type and search query. - * - * @return {ReactNode} The rendered feed data. - */ - const handleTogglePack = () => { setSelectedTypes((prevState) => ({ ...prevState, @@ -121,21 +118,15 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { setQueryString(value); }; - const urlPath = URL_PATHS[feedType]; - const createUrlPath = URL_PATHS[feedType] + 'create'; - const errorText = ERROR_MESSAGES[feedType]; - const handleCreateClick = () => { - // handle create click logic + const createUrlPath = URL_PATHS[feedType] + 'create'; router.push(createUrlPath); }; return ( - + { ItemSeparatorComponent={() => ( )} - keyExtractor={(item) => item?.id + item?.type} + keyExtractor={(item, index) => `${item?.id}_${item?.type}_${index}`} // Ensure unique keys renderItem={({ item }) => ( { feedType={item.type} /> )} - ListFooterComponent={() => } + ListFooterComponent={() => + isFetchingNextPage || isLoading ? ( + + ) : ( + + ) + } ListEmptyComponent={() => ( {ERROR_MESSAGES[feedType]} )} + refreshControl={} + onEndReached={fetchMoreData} // Trigger next page fetch + onEndReachedThreshold={0.5} // Trigger when 50% from the bottom showsVerticalScrollIndicator={false} - refreshControl={ - - } maxToRenderPerBatch={2} /> @@ -179,39 +176,14 @@ const Feed = ({ feedType = 'public' }: FeedProps) => { ); }; -const loadStyles = (theme) => { - const { currentTheme } = theme; - return { - mainContainer: { - flex: 1, - backgroundColor: currentTheme.colors.background, - fontSize: 18, - padding: 15, - ...(Platform.OS !== 'web' && { paddingBottom: 15, paddingTop: 0 }), - }, - // filterContainer: { - // backgroundColor: currentTheme.colors.card, - // padding: 15, - // fontSize: 18, - // width: '100%', - // borderRadius: 10, - // marginTop: 20, - // }, - // searchContainer: { - // flexDirection: 'row', - // alignItems: 'center', - // justifyContent: 'center', - // marginBottom: 10, - // padding: 10, - // borderRadius: 5, - // }, - // cardContainer: { - // flexDirection: 'row', - // flexWrap: 'wrap', - // justifyContent: 'space-around', - // alignItems: 'center', - // }, - }; -}; +const loadStyles = (theme) => ({ + mainContainer: { + flex: 1, + backgroundColor: theme.currentTheme.colors.background, + fontSize: 18, + padding: 15, + ...(Platform.OS !== 'web' && { paddingBottom: 15, paddingTop: 0 }), + }, +}); export default disableScreen(Feed, (props) => props.feedType === 'userTrips'); diff --git a/packages/app/modules/item/components/SearchItem/SearchItem.tsx b/packages/app/modules/item/components/SearchItem/SearchItem.tsx index 655dac414..e0f763529 100644 --- a/packages/app/modules/item/components/SearchItem/SearchItem.tsx +++ b/packages/app/modules/item/components/SearchItem/SearchItem.tsx @@ -13,7 +13,7 @@ export const SearchItem = () => { searchString={searchString} onChange={setSearchString} resultItemComponent={} - placeholder="Search Item" + placeholder="Search Global Item" results={results} onSelect={handleSearchResultClick} /> diff --git a/packages/app/modules/pack/components/EditPackModal.tsx b/packages/app/modules/pack/components/EditPackModal.tsx new file mode 100644 index 000000000..f8ec7f54c --- /dev/null +++ b/packages/app/modules/pack/components/EditPackModal.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { BaseModal } from '../../../../ui/src/modal/BaseModal'; +import RButton from '@packrat/ui/src/RButton'; +import RInput from '@packrat/ui/src/RInput'; + +import { View, Text } from 'react-native'; +import { useEditPack } from '../hooks/useEditPack'; +import RStack from '@packrat/ui/src/RStack'; +import RText from '@packrat/ui/src/RText'; +import { Switch } from 'tamagui'; +import RSwitch from '@packrat/ui/src/RSwitch'; + +interface EditPackModalProps { + isOpen?: boolean; + onClose?: () => void; + currentPack: any; + refetch?: () => void; +} + +export const EditPackModal: React.FC = ({ + isOpen, + onClose, + currentPack, + refetch, +}) => { + console.log({ currentPack }); + const [packName, setPackName] = useState(currentPack?.name ?? ''); + const [isPublic, setIsPublic] = useState(currentPack?.is_public ?? true); + const { editPack, isLoading, isError } = useEditPack(); + + const handleEditPack = async () => { + try { + editPack( + { id: currentPack.id, name: packName, is_public: isPublic }, + { + onSuccess: () => { + onClose?.(); + refetch?.(); + }, + }, + ); + } catch (e) { + console.error(e); + } + }; + + useEffect(() => { + if (isOpen) { + setPackName(currentPack?.name ?? ''); + setIsPublic(currentPack?.is_public ?? true); + } + }, [isOpen]); + + return ( + { + closeModal(); + onClose?.(); + }, + }, + ]} + isOpen={isOpen} + onClose={onClose} + > + setPackName(t)} + style={{ width: 200 }} + /> + + Public + setIsPublic((prev) => !prev)} + size="$1.5" + > + + + + + {isLoading ? 'Saving...' : 'Save'} + + {isError && ( + + Failed to save changes + + )} + + ); +}; diff --git a/packages/app/trpc.ts b/packages/app/trpc.ts index cbf53c24f..658b8eeed 100644 --- a/packages/app/trpc.ts +++ b/packages/app/trpc.ts @@ -36,7 +36,7 @@ const axiosFetch = async (url, options) => { // export const reactTrpc = createTRPCReact(); export const queryTrpc = createTRPCReact(); -export const trpc = queryTrpc.createClient({ +const trpcClientOpts = { links: [ httpBatchLink({ url: `${api}/trpc`, @@ -50,6 +50,16 @@ export const trpc = queryTrpc.createClient({ }), ], transformer: undefined, -}); +}; + +export const trpc = queryTrpc.createClient(trpcClientOpts); + +export const vanillaTrpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${api}/trpc`, + }), + ], +}); // For calling procedures imperatively (outside of a component) export const queryClient = new QueryClient(); diff --git a/packages/app/utils/userUtils.ts b/packages/app/utils/userUtils.ts index e18847d7f..cb88f7ab1 100644 --- a/packages/app/utils/userUtils.ts +++ b/packages/app/utils/userUtils.ts @@ -1,9 +1,11 @@ import { Storage } from 'app/utils/storage'; import { getQueryKey } from '@trpc/react-query'; -import { queryClient, queryTrpc } from 'app/trpc'; +import { queryClient, queryTrpc, vanillaTrpcClient } from 'app/trpc'; -export const logoutAuthUser = () => { - Storage.removeItem('token'); +export const logoutAuthUser = async () => { + await vanillaTrpcClient.logout.query(await Storage.getItem('refreshToken')); + await Storage.removeItem('token'); + await Storage.removeItem('refreshToken'); queryClient.setQueriesData( getQueryKey(queryTrpc.getMe, undefined, 'query'), null, diff --git a/packages/ui/src/Bento/forms/layouts/SignInScreen.tsx b/packages/ui/src/Bento/forms/layouts/SignInScreen.tsx index 50a04582c..714ca78d3 100644 --- a/packages/ui/src/Bento/forms/layouts/SignInScreen.tsx +++ b/packages/ui/src/Bento/forms/layouts/SignInScreen.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import * as LocalAuthentication from 'expo-local-authentication'; import { AnimatePresence, @@ -71,13 +72,14 @@ export function SignInScreen({ return ( statement-breakpoint +ALTER TABLE `user` DROP COLUMN `token`; \ No newline at end of file diff --git a/server/migrations/meta/0004_snapshot.json b/server/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..980f5c16d --- /dev/null +++ b/server/migrations/meta/0004_snapshot.json @@ -0,0 +1,1250 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "25256b91-d058-4509-ab3a-a877c2ce278a", + "prevId": "57e2d274-a728-4d09-9aff-74d3b92399fa", + "tables": { + "conversation": { + "name": "conversation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "itemTypeId": { + "name": "itemTypeId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history": { + "name": "history", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "geojson": { + "name": "geojson", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "geo_json_id": { + "name": "geo_json_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "properties": { + "name": "properties", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "item": { + "name": "item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "global": { + "name": "global", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "item_category_id_item_category_id_fk": { + "name": "item_category_id_item_category_id_fk", + "tableFrom": "item", + "tableTo": "item_category", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "item_owner_id_user_id_fk": { + "name": "item_owner_id_user_id_fk", + "tableFrom": "item", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "item_category": { + "name": "item_category", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "item_owners": { + "name": "item_owners", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_owners_item_id_item_id_fk": { + "name": "item_owners_item_id_item_id_fk", + "tableFrom": "item_owners", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_owners_owner_id_user_id_fk": { + "name": "item_owners_owner_id_user_id_fk", + "tableFrom": "item_owners", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "id": { + "columns": [ + "item_id", + "owner_id" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + }, + "item_packs": { + "name": "item_packs", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "item_packs_item_id_item_id_fk": { + "name": "item_packs_item_id_item_id_fk", + "tableFrom": "item_packs", + "tableTo": "item", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "item_packs_pack_id_pack_id_fk": { + "name": "item_packs_pack_id_pack_id_fk", + "tableFrom": "item_packs", + "tableTo": "pack", + "columnsFrom": [ + "pack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "id": { + "columns": [ + "item_id", + "pack_id" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + }, + "node": { + "name": "node", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "osm_id": { + "name": "osm_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lat": { + "name": "lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lon": { + "name": "lon", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "pack": { + "name": "pack", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "grades": { + "name": "grades", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"weight\":\"\",\"essentialItems\":\"\",\"redundancyAndVersatility\":\"\"}'" + }, + "scores": { + "name": "scores", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{\"weightScore\":0,\"essentialItemsScore\":0,\"redundancyAndVersatilityScore\":0}'" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pack'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "pack_owner_id_user_id_fk": { + "name": "pack_owner_id_user_id_fk", + "tableFrom": "pack", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "refresh_tokens": { + "name": "refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_user_id_fk": { + "name": "refresh_tokens_user_id_user_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "relation": { + "name": "relation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "osm_id": { + "name": "osm_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "osm_type": { + "name": "osm_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'relation'" + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "members": { + "name": "members", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "geo_json": { + "name": "geo_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "template": { + "name": "template", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pack'" + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_global_template": { + "name": "is_global_template", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "template_created_by_user_id_fk": { + "name": "template_created_by_user_id_fk", + "tableFrom": "template", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "trip": { + "name": "trip", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "weather": { + "name": "weather", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "packs_id": { + "name": "packs_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'trip'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "trip_owner_id_user_id_fk": { + "name": "trip_owner_id_user_id_fk", + "tableFrom": "trip", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trip_packs_id_pack_id_fk": { + "name": "trip_packs_id_pack_id_fk", + "tableFrom": "trip", + "tableTo": "pack", + "columnsFrom": [ + "packs_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "trip_geojsons": { + "name": "trip_geojsons", + "columns": { + "trip_id": { + "name": "trip_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "geojson_id": { + "name": "geojson_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "trip_geojsons_trip_id_trip_id_fk": { + "name": "trip_geojsons_trip_id_trip_id_fk", + "tableFrom": "trip_geojsons", + "tableTo": "trip", + "columnsFrom": [ + "trip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trip_geojsons_geojson_id_geojson_id_fk": { + "name": "trip_geojsons_geojson_id_geojson_id_fk", + "tableFrom": "trip_geojsons", + "tableTo": "geojson", + "columnsFrom": [ + "geojson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "id": { + "columns": [ + "geojson_id", + "trip_id" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_certified_guide": { + "name": "is_certified_guide", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_reset_token_expiration": { + "name": "password_reset_token_expiration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "offline_maps": { + "name": "offline_maps", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_image": { + "name": "profile_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferred_weather": { + "name": "preferred_weather", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'celsius'" + }, + "preferred_weight": { + "name": "preferred_weight", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'lb'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user_favorite_packs": { + "name": "user_favorite_packs", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pack_id": { + "name": "pack_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_favorite_packs_user_id_user_id_fk": { + "name": "user_favorite_packs_user_id_user_id_fk", + "tableFrom": "user_favorite_packs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorite_packs_pack_id_pack_id_fk": { + "name": "user_favorite_packs_pack_id_pack_id_fk", + "tableFrom": "user_favorite_packs", + "tableTo": "pack", + "columnsFrom": [ + "pack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "id": { + "columns": [ + "pack_id", + "user_id" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + }, + "way": { + "name": "way", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "osm_id": { + "name": "osm_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "osm_type": { + "name": "osm_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "geo_json": { + "name": "geo_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "way_nodes": { + "name": "way_nodes", + "columns": { + "way_id": { + "name": "way_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "way_nodes_way_id_way_id_fk": { + "name": "way_nodes_way_id_way_id_fk", + "tableFrom": "way_nodes", + "tableTo": "way", + "columnsFrom": [ + "way_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "way_nodes_node_id_node_id_fk": { + "name": "way_nodes_node_id_node_id_fk", + "tableFrom": "way_nodes", + "tableTo": "node", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "id": { + "columns": [ + "node_id", + "way_id" + ], + "name": "id" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/server/migrations/meta/_journal.json b/server/migrations/meta/_journal.json index 9e5b092be..9f0e82e18 100644 --- a/server/migrations/meta/_journal.json +++ b/server/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1721509132975, "tag": "0003_reflective_mimic", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1725521686790, + "tag": "0004_misty_gorilla_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/controllers/auth/index.ts b/server/src/controllers/auth/index.ts index c77341738..7b2837a43 100644 --- a/server/src/controllers/auth/index.ts +++ b/server/src/controllers/auth/index.ts @@ -1,3 +1,5 @@ export * from './checkCode'; export * from './emailExists'; export * from './updatePassword'; +export * from './refreshToken'; +export * from './logout'; diff --git a/server/src/controllers/auth/logout.ts b/server/src/controllers/auth/logout.ts new file mode 100644 index 000000000..f2223e6ad --- /dev/null +++ b/server/src/controllers/auth/logout.ts @@ -0,0 +1,14 @@ +import { publicProcedure } from 'src/trpc'; +import { z } from 'zod'; +import { logoutService as logout } from 'src/services/auth/auth.service'; +import { TRPCError } from '@trpc/server'; + +export function logoutRoute() { + return publicProcedure.input(z.string().min(1)).query(async (opts) => { + try { + return await logout(opts.input); + } catch { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' }); + } + }); +} diff --git a/server/src/controllers/auth/refreshToken.ts b/server/src/controllers/auth/refreshToken.ts new file mode 100644 index 000000000..b44b8c3a9 --- /dev/null +++ b/server/src/controllers/auth/refreshToken.ts @@ -0,0 +1,21 @@ +import { publicProcedure } from 'src/trpc'; +import { z } from 'zod'; +import { refreshTokenService } from 'src/services/auth/auth.service'; +import { TRPCError } from '@trpc/server'; + +export function refreshTokenRoute() { + return publicProcedure.input(z.string().min(1)).query(async (opts) => { + try { + return await refreshTokenService( + opts.ctx.env.JWT_SECRET, + opts.ctx.env.REFRESH_TOKEN_SECRET, + opts.input, + ); + } catch (error) { + // Refresh token expires + throw new TRPCError({ + code: 'UNAUTHORIZED', + }); + } + }); +} diff --git a/server/src/controllers/pack/getPublicPacks.ts b/server/src/controllers/pack/getPublicPacks.ts index e01e8b12b..b0f71d0c7 100644 --- a/server/src/controllers/pack/getPublicPacks.ts +++ b/server/src/controllers/pack/getPublicPacks.ts @@ -4,9 +4,11 @@ import { z } from 'zod'; import { type Context } from 'hono'; export const getPublicPacks = async (c: Context) => { + const query = c.req.query(); + const { page, limit } = query; try { - const { queryBy } = await c.req.query(); - const packs = await getPublicPacksService(queryBy); + const { queryBy } = query; + const packs = await getPublicPacksService(queryBy, Number(page), Number(limit)); return c.json( { packs, message: 'Public packs retrieved successfully' }, 200, @@ -21,10 +23,14 @@ export const getPublicPacks = async (c: Context) => { export function getPublicPacksRoute() { return protectedProcedure - .input(z.object({ queryBy: z.string() })) + .input(z.object({ + queryBy: z.string(), + page: z.number().optional(), + limit: z.number().optional(), + })) .query(async (opts) => { - const { queryBy } = opts.input; - const packs = await getPublicPacksService(queryBy); + const { queryBy, page, limit } = opts.input; + const packs = await getPublicPacksService(queryBy, page, limit); return packs; }); -} +} \ No newline at end of file diff --git a/server/src/controllers/user/userSignIn.ts b/server/src/controllers/user/userSignIn.ts index 9386d8c05..9bacbb892 100644 --- a/server/src/controllers/user/userSignIn.ts +++ b/server/src/controllers/user/userSignIn.ts @@ -7,7 +7,7 @@ export const userSignIn = async (c) => { const { email, password } = await c.req.json(); const userClass = new User(); const user = await userClass.findByCredentials(email, password); - await userClass.generateAuthToken(c.env.JWT_SECRET, user.id); + await userClass.generateAccessToken(c.env.JWT_SECRET, user.id); return c.json({ user }, 200); } catch (error) { return c.json({ error: `Failed to sign in: ${error.message}` }, 500); @@ -21,9 +21,18 @@ export function userSignInRoute() { const userClass = new User(); const user = await userClass.findByCredentials(input.email, input.password); if (!user) { - throw new Error('User not found'); + throw new Error('Wrong email or password'); } - await userClass.generateAuthToken(env.JWT_SECRET, user.id); - return user; + + const accessToken = await userClass.generateAccessToken( + env.JWT_SECRET, + user.id, + ); + const refreshToken = await userClass.generateRefreshToken( + env.REFRESH_TOKEN_SECRET, + user.id, + ); + + return { accessToken, refreshToken }; }); } diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 4ff7ce3f2..6cdd13f1c 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -32,7 +32,6 @@ export const user = sqliteTable('user', { name: text('name').notNull(), password: text('password').notNull(), // Trim + MinLength(7) + Validation email: text('email').notNull().unique(), // Lowercase + Trim + Validation - token: text('token'), // Trim googleId: text('google_id'), code: text('code'), is_certified_guide: integer('is_certified_guide', { @@ -484,6 +483,14 @@ export const tripGeojsonsRelations = relations(tripGeojsons, ({ one }) => ({ }), })); +export const refreshTokens = sqliteTable('refresh_tokens', { + id: text('id') + .primaryKey() + .$defaultFn(() => createId()), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + token: text('token').notNull(), +}); + export type User = InferSelectModel; export type InsertUser = InferInsertModel; export const insertUserSchema = createInsertSchema(user); diff --git a/server/src/drizzle/methods/User.ts b/server/src/drizzle/methods/User.ts index 64d2541fc..2175540b7 100644 --- a/server/src/drizzle/methods/User.ts +++ b/server/src/drizzle/methods/User.ts @@ -4,6 +4,7 @@ import { DbClient } from '../../db/client'; import { type InsertUser, user as UserTable, + refreshTokens, userFavoritePacks, } from '../../db/schema'; import bcrypt from 'bcryptjs'; @@ -35,7 +36,7 @@ export class User { return DbClient.instance .select() .from(UserTable) - .where(eq(UserTable.role, "admin")) + .where(eq(UserTable.role, 'admin')) .limit(1) .get(); } @@ -82,18 +83,41 @@ export class User { } } - async generateAuthToken(jwtSecret: string, id: string) { + async generateAccessToken(jwtSecret: string, id: string) { if (!jwtSecret) throw new Error('jwtSecret is not defined'); try { - const token = await jwt.sign({ id }, jwtSecret); - const filter = eq(UserTable.id, id); - await DbClient.instance.update(UserTable).set({ token }).where(filter); + const token = await jwt.sign( + { id, exp: Math.floor(Date.now() / 1000) + 60 * 30 }, // 30 mins expiry + jwtSecret, + ); return token; } catch (error) { - throw new Error(`Failed to generate token: ${error.message}`); + throw new Error(`Failed to generate access token: ${error.message}`); } } + async generateRefreshToken(jwtSecret: string, id: string) { + if (!jwtSecret) throw new Error('jwtSecret is not defined'); + try { + const token = await jwt.sign( + { id, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 14 }, // 14 days expiry + jwtSecret, + ); + await DbClient.instance + .insert(refreshTokens) + .values({ token, userId: id }); + return token; + } catch (error) { + throw new Error(`Failed to generate refresh token: ${error.message}`); + } + } + + async deleteRefreshToken(token: string) { + await DbClient.instance + .delete(refreshTokens) + .where(eq(refreshTokens.token, token)); + } + async generateResetToken( jwtSecret: string, clientUrl: string, diff --git a/server/src/drizzle/methods/pack.ts b/server/src/drizzle/methods/pack.ts index 1d8520c4f..8134bc0f0 100644 --- a/server/src/drizzle/methods/pack.ts +++ b/server/src/drizzle/methods/pack.ts @@ -151,6 +151,8 @@ export class Pack { sortOption, ownerId, is_public, + page , + limit , } = options; const filterConditions = []; @@ -169,11 +171,17 @@ export class Pack { includeRelated, completeItems: true, }); + + const offset = (page - 1) * limit; + const packs = await DbClient.instance.query.pack.findMany({ - ...(modifiedFilter && { where: modifiedFilter }), - orderBy: orderByFunction, - ...(includeRelated ? relations : {}), - }); + ...(modifiedFilter && { where: modifiedFilter }), + orderBy: orderByFunction, + ...(includeRelated ? relations : {}), + offset: offset, + limit: limit, + }); + return (await packs).map((pack: any) => ({ ...pack, scores: JSON.parse(pack.scores as string), diff --git a/server/src/routes/trpcRouter.ts b/server/src/routes/trpcRouter.ts index e399ec51c..3fddd97c8 100644 --- a/server/src/routes/trpcRouter.ts +++ b/server/src/routes/trpcRouter.ts @@ -18,6 +18,8 @@ import { checkCodeRoute, emailExistsRoute, updatePasswordRoute, + refreshTokenRoute, + logoutRoute, } from '../controllers/auth'; import { getWeatherRoute } from '../controllers/weather'; import { @@ -96,6 +98,8 @@ export const appRouter = trpcRouter({ getUserById: getUserByIdRoute(), signIn: userSignInRoute(), signUp: signUpRoute(), + logout: logoutRoute(), + refreshToken: refreshTokenRoute(), resetPassword: resetPasswordRoute(), getGoogleAuthURL: getGoogleAuthURLRoute(), googleSignin: googleSigninRoute(), diff --git a/server/src/services/auth/auth.service.ts b/server/src/services/auth/auth.service.ts new file mode 100644 index 000000000..eed20bf3f --- /dev/null +++ b/server/src/services/auth/auth.service.ts @@ -0,0 +1,2 @@ +export * from './refreshTokenService'; +export * from './logoutService'; diff --git a/server/src/services/auth/logoutService.ts b/server/src/services/auth/logoutService.ts new file mode 100644 index 000000000..fc5a247ff --- /dev/null +++ b/server/src/services/auth/logoutService.ts @@ -0,0 +1,7 @@ +import { User as UserRepository } from 'src/drizzle/methods/User'; + +export const logoutService = async (refreshToken: string) => { + const userRepository = new UserRepository(); + + await userRepository.deleteRefreshToken(refreshToken); +}; diff --git a/server/src/services/auth/refreshTokenService.ts b/server/src/services/auth/refreshTokenService.ts new file mode 100644 index 000000000..5d45e5edb --- /dev/null +++ b/server/src/services/auth/refreshTokenService.ts @@ -0,0 +1,24 @@ +import * as jwt from 'hono/jwt'; +import { User as UserRepository } from 'src/drizzle/methods/User'; + +export const refreshTokenService = async ( + jwtSecret: string, + refreshTokenSecret: string, + token: string, +) => { + const { id: userId } = await jwt.verify(token, refreshTokenSecret); // also checks expiry + + const userRepository = new UserRepository(); + + const accessToken = await userRepository.generateAccessToken( + jwtSecret, + userId as string, + ); + const refreshToken = await userRepository.generateRefreshToken( + refreshTokenSecret, + userId as string, + ); + await userRepository.deleteRefreshToken(token); // revoke former refreshToken + + return { accessToken, refreshToken }; +}; diff --git a/server/src/services/pack/getPublicPacksService.ts b/server/src/services/pack/getPublicPacksService.ts index 0bfeba40a..4861445b2 100644 --- a/server/src/services/pack/getPublicPacksService.ts +++ b/server/src/services/pack/getPublicPacksService.ts @@ -5,9 +5,12 @@ import { SORT_OPTIONS, DEFAULT_SORT, sortFunction } from '../../utils/pack'; * Retrieves public packs based on the provided query parameter. * * @param {string} queryBy - Specifies how the public packs should be sorted. + * @param {number} page - The page number for pagination. + * @param {number} limit - The number of items per page. * @return {Promise} An array of public packs. */ -export async function getPublicPacksService(queryBy: string = 'createdAt') { + +export async function getPublicPacksService(queryBy: string = 'createdAt', page: number, limit: number) { try { const packClass = new Pack(); const sortOption = queryBy ? SORT_OPTIONS[queryBy] : DEFAULT_SORT; @@ -15,6 +18,8 @@ export async function getPublicPacksService(queryBy: string = 'createdAt') { sortOption, includeRelated: true, is_public: true, + page, + limit, }); // Apply sorting if necessary @@ -29,4 +34,4 @@ export async function getPublicPacksService(queryBy: string = 'createdAt') { } catch (error) { throw new Error('Packs cannot be found: ' + error.message); } -} +} \ No newline at end of file diff --git a/server/src/trpc/utils/auth.ts b/server/src/trpc/utils/auth.ts index 8520da6bb..75b9cadb2 100644 --- a/server/src/trpc/utils/auth.ts +++ b/server/src/trpc/utils/auth.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { User } from '../../drizzle/methods/User'; +import { User as UserRepository } from '../../drizzle/methods/User'; +import { type User } from 'src/db/schema'; import * as jwt from 'hono/jwt'; // import * as jwt from 'hono/jwt'; @@ -30,31 +31,21 @@ const extractToken = (req: Request): string | null => { /** * Finds the user based on the verified token. - * @param {PrismaClient} prisma - The Prisma client. - * @param {JwtPayload} decoded - The decoded JWT payload. * @param {string} token - The JWT token. - * @returns {Promise} - The user associated with the token. - * @throws {Error} If user is not found. + * @param {string} jwtSecret - The JWT secret. + * @returns {Promise} - The user associated with the token. Resolves to null if token couldn't be verified or user is not found. */ -const findUser = async (token: string, jwtSecret: string) => { - const userClass = new User(); +const findUser = async (token: string, jwtSecret: string): Promise => { + const userRepository = new UserRepository(); + let user: User = null; // const user: any = await userClass.validateResetToken(token, jwtSecret); try { const decoded = await jwt.verify(token, jwtSecret); - const user = await userClass.findUser({ userId: decoded.id }); - if (!user) - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User associated with this token not found.', - }); - - return user; + user = await userRepository.findUser({ userId: decoded.id as string }); } catch { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'User associated with this token not found.', - }); + // pass } + return user; }; const extractTokenAndGetUser = async (req: Request, jwtSecret: string) => { diff --git a/server/worker-configuration.d.ts b/server/worker-configuration.d.ts index 927e1cc9e..f366684ae 100644 --- a/server/worker-configuration.d.ts +++ b/server/worker-configuration.d.ts @@ -7,6 +7,7 @@ interface Env { STMP_EMAIL: 'test'; STMP_PASSWORD: 'test'; JWT_SECRET: 'test'; + REFRESH_TOKEN_SECRET: string; SEND_GRID_API_KEY: 'test'; MAPBOX_ACCESS_TOKEN: 'test'; OSM_URI: 'test'; diff --git a/server/wrangler.toml.example b/server/wrangler.toml.example index 475804b00..7223a0997 100644 --- a/server/wrangler.toml.example +++ b/server/wrangler.toml.example @@ -25,6 +25,7 @@ GOOGLE_CLIENT_SECRET="" STMP_EMAIL="" STMP_PASSWORD="" JWT_SECRET="" +REFRESH_TOKEN_SECRET="" SEND_GRID_API_KEY="" MAPBOX_ACCESS_TOKEN="" OSM_URI="" @@ -68,6 +69,7 @@ GOOGLE_CLIENT_SECRET="" STMP_EMAIL="" STMP_PASSWORD="" JWT_SECRET="" +REFRESH_TOKEN_SECRET="" SEND_GRID_API_KEY="" MAPBOX_ACCESS_TOKEN="" OSM_URI=""